Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9505011d71 | ||
|
|
2579aaf2db | ||
|
|
2b7784a226 | ||
|
|
8555a97b39 | ||
|
|
c2dd685a4b | ||
|
|
66fc819379 | ||
|
|
4f1f7e15c4 | ||
|
|
a826c52e2e | ||
|
|
8b2c22aaf1 | ||
|
|
aecfee4469 | ||
|
|
9c785a9c5b | ||
|
|
2fe10387ee | ||
|
|
8642b9e615 | ||
|
|
2a604c8719 | ||
|
|
9c0a0a1086 | ||
|
|
1a4256f997 | ||
|
|
80ae0380a2 | ||
|
|
fd01282798 | ||
|
|
f7ba2eaa62 | ||
|
|
9485a95c0e | ||
|
|
f1ab605bb9 | ||
|
|
3472ff9ba3 | ||
|
|
577dc06d55 | ||
|
|
f4771d1495 | ||
|
|
4b3ce53acf | ||
|
|
ca267047d3 | ||
|
|
0543163817 | ||
|
|
72f43a5821 | ||
|
|
aea9034adf | ||
|
|
6e6f822761 | ||
|
|
137aa20a10 | ||
|
|
d7231c5435 | ||
|
|
be116436d9 | ||
|
|
aed7505e4b | ||
|
|
590a020303 | ||
|
|
15c6ca7628 | ||
|
|
49ba89c22a | ||
|
|
c295271757 | ||
|
|
12b483ff05 | ||
|
|
7502a38033 | ||
|
|
4e74dbf218 | ||
|
|
fa5c53b571 | ||
|
|
76fed82e91 | ||
|
|
43ac32282c | ||
|
|
96ecef0a92 | ||
|
|
5e87dead44 | ||
|
|
7af67d2944 | ||
|
|
ce59537006 | ||
|
|
f87e15b941 | ||
|
|
1f82bdde3d | ||
|
|
0d2393d866 | ||
|
|
bea21d7b99 | ||
|
|
3dc65c48b3 | ||
|
|
18ab333afc | ||
|
|
fd91f88a7e | ||
|
|
13c164d81f | ||
|
|
f33eb3f678 | ||
|
|
e893682723 | ||
|
|
d9c201e3d1 | ||
|
|
a5fcfc231d | ||
|
|
6ba03a48c8 | ||
|
|
8434c7d0cb | ||
|
|
3854b2bd53 | ||
|
|
08d217360e | ||
|
|
b762135554 | ||
|
|
644334c63b | ||
|
|
a09e1777c4 | ||
|
|
9cb59fe1b4 |
+10
-3
@@ -37,7 +37,7 @@ In order to work with the Umbraco source code locally, first make sure you have
|
||||
|
||||
### Familiarizing yourself with the code
|
||||
|
||||
Umbraco is a .NET application using C#. The solution is broken down into multiple projects. There are several class libraries. The `Umbraco.Web.UI` project is the main project that hosts the back office and login screen. This is the project you will want to run to see your changes.
|
||||
Umbraco is a .NET application using C#. The solution is broken down into multiple projects. There are several class libraries. The `Umbraco.Web.UI` project is the main project that hosts the back office and login screen. This is the project you will want to run to see your changes.
|
||||
|
||||
There are two web projects in the solution with client-side assets based on TypeScript, `Umbraco.Web.UI.Client` and `Umbraco.Web.UI.Login`.
|
||||
|
||||
@@ -73,13 +73,20 @@ Just be careful not to include this change in your PR.
|
||||
|
||||
Conversely, if you are working on front-end only, you want to build the back-end once and then run it. Before you do so, update the configuration in `appSettings.json` to add the following under `Umbraco:Cms:Security`:
|
||||
|
||||
```
|
||||
```json
|
||||
"BackOfficeHost": "http://localhost:5173",
|
||||
"AuthorizeCallbackPathName": "/oauth_complete",
|
||||
"AuthorizeCallbackLogoutPathName": "/logout",
|
||||
"AuthorizeCallbackErrorPathName": "/error"
|
||||
"AuthorizeCallbackErrorPathName": "/error",
|
||||
"BackOfficeTokenCookie": {
|
||||
"Enabled": true,
|
||||
"SameSite": "None"
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> If you get stuck in a login loop, try clearing your browser cookies for localhost, and make sure that the `BackOfficeTokenCookie` settings are correct. Namely, that `SameSite` should be set to `None` when running the front-end server separately.
|
||||
|
||||
Then run Umbraco from the command line.
|
||||
|
||||
```
|
||||
|
||||
@@ -38,6 +38,14 @@ Some important documentation links to get you started:
|
||||
- [Getting to know Umbraco](https://docs.umbraco.com/umbraco-cms/fundamentals/get-to-know-umbraco)
|
||||
- [Tutorials for creating a basic website and customizing the editing experience](https://docs.umbraco.com/umbraco-cms/tutorials/overview)
|
||||
|
||||
## Backoffice Preview
|
||||
|
||||
Want to see the latest backoffice UI in action? Check out our live preview:
|
||||
|
||||
**[backofficepreview.umbraco.com](https://backofficepreview.umbraco.com/)**
|
||||
|
||||
This preview is automatically deployed from the main branch and showcases the latest backoffice features and improvements. It runs from mock data and persistent edits are not supported.
|
||||
|
||||
## Get help
|
||||
|
||||
If you need a bit of feedback while building your Umbraco projects, we are [chatty on Discord](https://discord.umbraco.com). Our Discord server serves as a social space for all Umbracians. If you have any questions or need some help with a problem, head over to our [dedicated forum](https://forum.umbraco.com/) where the Umbraco Community will be happy to help.
|
||||
|
||||
@@ -110,7 +110,11 @@ Use this for frontend-only development with hot module reloading:
|
||||
"BackOfficeHost": "http://localhost:5173",
|
||||
"AuthorizeCallbackPathName": "/oauth_complete",
|
||||
"AuthorizeCallbackLogoutPathName": "/logout",
|
||||
"AuthorizeCallbackErrorPathName": "/error"
|
||||
"AuthorizeCallbackErrorPathName": "/error",
|
||||
"BackOfficeTokenCookie": {
|
||||
"Enabled": true,
|
||||
"SameSite": "None"
|
||||
}
|
||||
```
|
||||
2. Run backend: `cd src/Umbraco.Web.UI && dotnet run --no-build`
|
||||
3. Run frontend dev server: `cd src/Umbraco.Web.UI.Client && npm run dev:server`
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
for (const item of items) {
|
||||
const releaseLabels = (item.labels || [])
|
||||
.map(l => (typeof l === "string" ? l : l.name)) // always get the name
|
||||
.filter(n => typeof n === "string" && n.startsWith("release/"));
|
||||
.filter(n => typeof n === "string" && n.startsWith("release/") && n !== "release/no-notes");
|
||||
if (releaseLabels.length === 0) continue;
|
||||
|
||||
core.info(`#${item.number}: ${releaseLabels.join(", ")}`);
|
||||
|
||||
Vendored
+4
-1
@@ -105,7 +105,10 @@
|
||||
"UMBRACO__CMS__SECURITY__BACKOFFICEHOST": "http://localhost:5173",
|
||||
"UMBRACO__CMS__SECURITY__AUTHORIZECALLBACKPATHNAME": "/oauth_complete",
|
||||
"UMBRACO__CMS__SECURITY__AUTHORIZECALLBACKLOGOUTPATHNAME": "/logout",
|
||||
"UMBRACO__CMS__SECURITY__AUTHORIZECALLBACKERRORPATHNAME": "/error"
|
||||
"UMBRACO__CMS__SECURITY__AUTHORIZECALLBACKERRORPATHNAME": "/error",
|
||||
"UMBRACO__CMS__SECURITY__KEEPUSERLOGGEDIN": "true",
|
||||
"UMBRACO__CMS__SECURITY__BACKOFFICETOKENCOOKIE__ENABLED": "true",
|
||||
"UMBRACO__CMS__SECURITY__BACKOFFICETOKENCOOKIE__SAMESITE": "None"
|
||||
},
|
||||
"sourceFileMap": {
|
||||
"/Views": "${workspaceFolder}/Umbraco.Web.UI/Views"
|
||||
|
||||
@@ -34,6 +34,10 @@ parameters:
|
||||
displayName: Upload API docs
|
||||
type: boolean
|
||||
default: false
|
||||
- name: uploadDependencyTrack
|
||||
displayName: Upload BOMs to Dependency Track
|
||||
type: boolean
|
||||
default: false
|
||||
- name: forceReleaseTestFilter
|
||||
displayName: Force to use the release test filters
|
||||
type: boolean
|
||||
@@ -103,6 +107,15 @@ stages:
|
||||
command: build
|
||||
projects: $(solution)
|
||||
arguments: "--configuration $(buildConfiguration) --no-restore --property:ContinuousIntegrationBuild=true --property:GeneratePackageOnBuild=true --property:PackageOutputPath=$(Build.ArtifactStagingDirectory)/nupkg"
|
||||
- powershell: |
|
||||
dotnet tool install --global CycloneDX
|
||||
dotnet-CycloneDX $(solution) --output $(Build.ArtifactStagingDirectory)/bom --filename bom-dotnet.xml
|
||||
displayName: 'Generate Backend BOM'
|
||||
- powershell: |
|
||||
npm install --global @cyclonedx/cyclonedx-npm
|
||||
cyclonedx-npm -o $(Build.ArtifactStagingDirectory)\bom\bom-login.xml --ignore-npm-errors --verbose
|
||||
displayName: Generate Login UI BOM
|
||||
workingDirectory: src/Umbraco.Web.UI.Login
|
||||
- task: PublishPipelineArtifact@1
|
||||
displayName: Publish nupkg
|
||||
inputs:
|
||||
@@ -113,6 +126,11 @@ stages:
|
||||
inputs:
|
||||
targetPath: $(Build.SourcesDirectory)
|
||||
artifactName: build_output
|
||||
- task: PublishPipelineArtifact@1
|
||||
displayName: Publish Backend BOM
|
||||
inputs:
|
||||
targetPath: $(Build.ArtifactStagingDirectory)/bom
|
||||
artifactName: bom-backend
|
||||
|
||||
- job: B
|
||||
displayName: Build Bellissima Package
|
||||
@@ -124,6 +142,11 @@ stages:
|
||||
lfs: false,
|
||||
fetchDepth: 500
|
||||
- template: templates/backoffice-install.yml
|
||||
- powershell: |
|
||||
npm install --global @cyclonedx/cyclonedx-npm
|
||||
cyclonedx-npm -o $(Build.ArtifactStagingDirectory)/bom/bom-backoffice.xml --ignore-npm-errors --verbose
|
||||
displayName: Generate Backoffice UI BOM
|
||||
workingDirectory: src/Umbraco.Web.UI.Client
|
||||
- script: npm run build:for:npm
|
||||
displayName: Run build:for:npm
|
||||
workingDirectory: src/Umbraco.Web.UI.Client
|
||||
@@ -140,6 +163,35 @@ stages:
|
||||
inputs:
|
||||
targetPath: $(Build.ArtifactStagingDirectory)/npm
|
||||
artifactName: npm
|
||||
- publish: $(Build.ArtifactStagingDirectory)/bom
|
||||
artifact: bom-frontend
|
||||
displayName: 'Publish Frontend BOM'
|
||||
|
||||
- stage: E2E_BOM
|
||||
displayName: E2E Tests BOM Generation
|
||||
dependsOn: []
|
||||
jobs:
|
||||
- job:
|
||||
displayName: E2E Generate BOM
|
||||
pool:
|
||||
vmImage: "ubuntu-latest"
|
||||
steps:
|
||||
- checkout: self
|
||||
submodules: false
|
||||
lfs: false,
|
||||
fetchDepth: 500
|
||||
- template: templates/e2e-install.yml
|
||||
parameters:
|
||||
nodeVersion: ${{ variables.nodeVersion }}
|
||||
npm_config_cache: ${{ variables.npm_config_cache }}
|
||||
- powershell: |
|
||||
npm install --global @cyclonedx/cyclonedx-npm
|
||||
cyclonedx-npm -o $(Build.ArtifactStagingDirectory)/bom/bom-e2e.xml --ignore-npm-errors --verbose
|
||||
displayName: Generate E2E Tests BOM
|
||||
workingDirectory: tests/Umbraco.Tests.AcceptanceTest
|
||||
- publish: $(Build.ArtifactStagingDirectory)/bom
|
||||
artifact: bom-e2e
|
||||
displayName: 'Publish E2E BOM'
|
||||
|
||||
- stage: Build_Docs
|
||||
condition: and(succeeded(), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.buildApiDocs}}))
|
||||
@@ -668,6 +720,34 @@ stages:
|
||||
ASPNETCORE_URLS: ${{ variables.ASPNETCORE_URLS }}
|
||||
DatabaseType: ${{ variables.DatabaseType }}
|
||||
|
||||
- stage: Dependency_Track
|
||||
displayName: Dependency Track
|
||||
dependsOn:
|
||||
- Build
|
||||
- E2E_BOM
|
||||
condition: and(succeeded(), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.uploadDependencyTrack}}))
|
||||
variables:
|
||||
# Determine Umbraco version based on whether it's a public release or not. If public release, use major version, else use full NuGet package version.
|
||||
umbracoVersion: $[ iif(eq(stageDependencies.Build.A.outputs['build.NBGV_PublicRelease'], 'True'), stageDependencies.Build.A.outputs['build.NBGV_VersionMajor'], stageDependencies.Build.A.outputs['build.NBGV_NuGetPackageVersion']) ]
|
||||
jobs:
|
||||
- template: templates/dependency-track.yml
|
||||
parameters:
|
||||
projectName: "Umbraco-CMS"
|
||||
umbracoVersion: $(umbracoVersion)
|
||||
projects:
|
||||
- name: "Backend"
|
||||
artifact: "bom-backend"
|
||||
bomFilePath: "bom-dotnet.xml"
|
||||
- name: "Login"
|
||||
artifact: "bom-backend"
|
||||
bomFilePath: "bom-login.xml"
|
||||
- name: "Backoffice"
|
||||
artifact: "bom-frontend"
|
||||
bomFilePath: "bom-backoffice.xml"
|
||||
- name: "E2E"
|
||||
artifact: "bom-e2e"
|
||||
bomFilePath: "bom-e2e.xml"
|
||||
|
||||
###############################################
|
||||
## Release
|
||||
###############################################
|
||||
@@ -874,3 +954,4 @@ stages:
|
||||
ContainerName: "$web"
|
||||
BlobPrefix: v$(umbracoMajorVersion)/ui-api
|
||||
CleanTargetBeforeCopy: true
|
||||
|
||||
|
||||
@@ -26,38 +26,18 @@ steps:
|
||||
artifact: nupkg
|
||||
path: $(Agent.BuildDirectory)/app/nupkg
|
||||
|
||||
- task: NodeTool@0
|
||||
displayName: Use Node.js $(nodeVersion)
|
||||
inputs:
|
||||
versionSpec: $(nodeVersion)
|
||||
|
||||
- task: UseDotNet@2
|
||||
displayName: Use .NET SDK from global.json
|
||||
inputs:
|
||||
useGlobalJson: true
|
||||
|
||||
- pwsh: |
|
||||
"UMBRACO_USER_LOGIN=${{ parameters.PlaywrightUserEmail }}
|
||||
UMBRACO_USER_PASSWORD=${{ parameters.PlaywrightPassword }}
|
||||
URL=${{ parameters.ASPNETCORE_URLS }}
|
||||
STORAGE_STAGE_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/playwright/.auth/user.json
|
||||
CONSOLE_ERRORS_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/console-errors.json" | Out-File .env
|
||||
displayName: Generate .env
|
||||
workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest
|
||||
|
||||
# Cache and restore NPM packages
|
||||
- task: Cache@2
|
||||
displayName: Cache NPM packages
|
||||
inputs:
|
||||
key: 'npm_e2e | "$(Agent.OS)" | $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/package-lock.json'
|
||||
restoreKeys: |
|
||||
npm_e2e | "$(Agent.OS)"
|
||||
npm_e2e
|
||||
path: ${{ parameters.npm_config_cache }}
|
||||
|
||||
- script: npm ci --no-fund --no-audit --prefer-offline
|
||||
workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest
|
||||
displayName: Restore NPM packages
|
||||
- template: templates/e2e-install.yml
|
||||
parameters:
|
||||
nodeVersion: ${{ parameters.nodeVersion }}
|
||||
npm_config_cache: ${{ parameters.npm_config_cache }}
|
||||
PlaywrightUserEmail: ${{ parameters.PlaywrightUserEmail }}
|
||||
PlaywrightPassword: ${{ parameters.PlaywrightPassword }}
|
||||
ASPNETCORE_URLS: ${{ parameters.ASPNETCORE_URLS }}
|
||||
|
||||
# Install Template
|
||||
- pwsh: |
|
||||
|
||||
@@ -4,12 +4,11 @@ pr: none
|
||||
trigger: none
|
||||
|
||||
schedules:
|
||||
- cron: '0 0 * * *'
|
||||
displayName: Daily midnight build
|
||||
- cron: '0 6 * * *'
|
||||
displayName: Daily 6 AM build (v16/dev)
|
||||
branches:
|
||||
include:
|
||||
- v15/dev
|
||||
- main
|
||||
- v16/dev
|
||||
|
||||
parameters:
|
||||
- name: skipIntegrationTests
|
||||
@@ -294,7 +293,8 @@ stages:
|
||||
|
||||
- stage: DefaultConfigE2E
|
||||
displayName: Default Config E2E Tests
|
||||
dependsOn: Build
|
||||
dependsOn: Integration
|
||||
condition: always()
|
||||
variables:
|
||||
npm_config_cache: $(Pipeline.Workspace)/.npm_e2e
|
||||
# Enable console logging in Release mode
|
||||
@@ -475,7 +475,8 @@ stages:
|
||||
|
||||
- stage: AdditionalConfigE2E
|
||||
displayName: Additional Config E2E Tests
|
||||
dependsOn: Build
|
||||
dependsOn: DefaultConfigE2E
|
||||
condition: always()
|
||||
variables:
|
||||
npm_config_cache: $(Pipeline.Workspace)/.npm_e2e
|
||||
ASPNETCORE_URLS: https://localhost:44331
|
||||
@@ -674,4 +675,4 @@ stages:
|
||||
--data "$PAYLOAD" \
|
||||
"$SLACK_WEBHOOK_URL"
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: $(E2ESLACKWEBHOOKURL)
|
||||
SLACK_WEBHOOK_URL: $(E2ESLACKWEBHOOKURL)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
parameters:
|
||||
- name: projectName
|
||||
type: string
|
||||
- name: umbracoVersion
|
||||
type: string
|
||||
- name: projects
|
||||
type: object
|
||||
|
||||
jobs:
|
||||
- job: Create_DT_Project
|
||||
displayName: Create Dependency Track Project
|
||||
steps:
|
||||
- checkout: none
|
||||
|
||||
- bash: |
|
||||
project_id=$(curl --no-progress-meter -H "X-Api-Key: $(DT_API_KEY)" "$(DT_API_URL)/v1/project/lookup?name=${{ parameters.projectName }}&version=${{ parameters.umbracoVersion }}" | jq -r '.uuid')
|
||||
if [ "$project_id" != "null" ] && [ -n "$project_id" ]; then
|
||||
echo "Project '${{ parameters.projectName }}' with version '${{ parameters.umbracoVersion }}' already exists (ID: $project_id)."
|
||||
else
|
||||
project_id=$(curl --no-progress-meter \
|
||||
-X PUT "$(DT_API_URL)/v1/project" \
|
||||
-H "X-Api-Key: $(DT_API_KEY)" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "${{ parameters.projectName }}", "version": "${{ parameters.umbracoVersion }}", "collectionLogic": "AGGREGATE_DIRECT_CHILDREN"}' \
|
||||
| jq -r '.uuid')
|
||||
if [ -z "$project_id" ] || [ "$project_id" == "null" ]; then
|
||||
echo "Failed to create project '${{ parameters.projectName }}' version '${{ parameters.umbracoVersion }}'."
|
||||
exit 1
|
||||
fi
|
||||
echo "Created project '${{ parameters.projectName }}' with version '${{ parameters.umbracoVersion }}' (ID: $project_id)."
|
||||
fi
|
||||
displayName: Ensure main project exists in Dependency Track
|
||||
|
||||
- ${{ each project in parameters.projects }}:
|
||||
- job:
|
||||
displayName: Upload ${{ project.name }} BOM
|
||||
dependsOn: Create_DT_Project
|
||||
steps:
|
||||
- checkout: none
|
||||
|
||||
- download: current
|
||||
artifact: ${{ project.artifact }}
|
||||
displayName: Download ${{ project.artifact }} artifact
|
||||
|
||||
- script: |
|
||||
curl --no-progress-meter --fail-with-body \
|
||||
-X POST "$(DT_API_URL)/v1/bom" \
|
||||
-H "X-Api-Key: $(DT_API_KEY)" \
|
||||
-H "Content-Type: multipart/form-data" \
|
||||
-F "autoCreate=true" \
|
||||
-F "projectName=${{ parameters.projectName }}-${{ project.name }}" \
|
||||
-F "projectVersion=${{ parameters.umbracoVersion }}" \
|
||||
-F "parentName=${{ parameters.projectName }}" \
|
||||
-F "parentVersion=${{ parameters.umbracoVersion }}" \
|
||||
-F "bom=@$(Pipeline.Workspace)/${{ project.artifact }}/${{ project.bomFilePath }}"
|
||||
displayName: Upload ${{ project.name }} BOM to Dependency Track
|
||||
@@ -0,0 +1,49 @@
|
||||
parameters:
|
||||
- name: nodeVersion
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
- name: npm_config_cache
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
- name: PlaywrightUserEmail
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
- name: PlaywrightPassword
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
- name: ASPNETCORE_URLS
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
steps:
|
||||
- task: NodeTool@0
|
||||
displayName: Use Node.js $(nodeVersion)
|
||||
inputs:
|
||||
versionSpec: $(nodeVersion)
|
||||
|
||||
- pwsh: |
|
||||
"UMBRACO_USER_LOGIN=${{ parameters.PlaywrightUserEmail }}
|
||||
UMBRACO_USER_PASSWORD=${{ parameters.PlaywrightPassword }}
|
||||
URL=${{ parameters.ASPNETCORE_URLS }}
|
||||
STORAGE_STAGE_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/playwright/.auth/user.json
|
||||
CONSOLE_ERRORS_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/console-errors.json" | Out-File .env
|
||||
displayName: Generate .env
|
||||
workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest
|
||||
|
||||
# Cache and restore NPM packages
|
||||
- task: Cache@2
|
||||
displayName: Cache NPM packages
|
||||
inputs:
|
||||
key: 'npm_e2e | "$(Agent.OS)" | $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/package-lock.json'
|
||||
restoreKeys: |
|
||||
npm_e2e | "$(Agent.OS)"
|
||||
npm_e2e
|
||||
path: ${{ parameters.npm_config_cache }}
|
||||
|
||||
- script: npm ci --no-fund --no-audit --prefer-offline
|
||||
workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest
|
||||
displayName: Restore NPM packages
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "9.0.100",
|
||||
"version": "9.0.306",
|
||||
"rollForward": "latestFeature",
|
||||
"allowPrerelease": false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OpenIddict.Server;
|
||||
using OpenIddict.Validation;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Web.Common.Security;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.DependencyInjection;
|
||||
|
||||
internal sealed class HideBackOfficeTokensHandler
|
||||
: IOpenIddictServerHandler<OpenIddictServerEvents.ApplyTokenResponseContext>,
|
||||
IOpenIddictServerHandler<OpenIddictServerEvents.ApplyAuthorizationResponseContext>,
|
||||
IOpenIddictServerHandler<OpenIddictServerEvents.ExtractTokenRequestContext>,
|
||||
IOpenIddictValidationHandler<OpenIddictValidationEvents.ProcessAuthenticationContext>,
|
||||
INotificationHandler<UserLogoutSuccessNotification>
|
||||
{
|
||||
private const string RedactedTokenValue = "[redacted]";
|
||||
private const string AccessTokenCookieKey = "__Host-umbAccessToken";
|
||||
private const string RefreshTokenCookieKey = "__Host-umbRefreshToken";
|
||||
private const string PkceCodeCookieKey = "__Host-umbPkceCode";
|
||||
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly IDataProtectionProvider _dataProtectionProvider;
|
||||
private readonly BackOfficeTokenCookieSettings _backOfficeTokenCookieSettings;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
|
||||
public HideBackOfficeTokensHandler(
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IDataProtectionProvider dataProtectionProvider,
|
||||
IOptions<BackOfficeTokenCookieSettings> backOfficeTokenCookieSettings,
|
||||
IOptions<GlobalSettings> globalSettings)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_dataProtectionProvider = dataProtectionProvider;
|
||||
_backOfficeTokenCookieSettings = backOfficeTokenCookieSettings.Value;
|
||||
_globalSettings = globalSettings.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This is invoked when tokens (access and refresh tokens) are issued to a client. For the back-office client,
|
||||
/// we will intercept the response, write the tokens from the response into HTTP-only cookies, and redact the
|
||||
/// tokens from the response, so they are not exposed to the client.
|
||||
/// </summary>
|
||||
public ValueTask HandleAsync(OpenIddictServerEvents.ApplyTokenResponseContext context)
|
||||
{
|
||||
if (context.Request?.ClientId is not Constants.OAuthClientIds.BackOffice)
|
||||
{
|
||||
// Only ever handle the back-office client.
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
HttpContext httpContext = GetHttpContext();
|
||||
|
||||
if (context.Response.AccessToken is not null)
|
||||
{
|
||||
SetCookie(httpContext, AccessTokenCookieKey, context.Response.AccessToken);
|
||||
context.Response.AccessToken = RedactedTokenValue;
|
||||
}
|
||||
|
||||
if (context.Response.RefreshToken is not null)
|
||||
{
|
||||
SetCookie(httpContext, RefreshTokenCookieKey, context.Response.RefreshToken);
|
||||
context.Response.RefreshToken = RedactedTokenValue;
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This is invoked when a PKCE code is issued to the client. For the back-office client, we will intercept the
|
||||
/// response, write the PKCE code from the response into a HTTP-only cookie, and redact the code from the response,
|
||||
/// so it's not exposed to the client.
|
||||
/// </summary>
|
||||
public ValueTask HandleAsync(OpenIddictServerEvents.ApplyAuthorizationResponseContext context)
|
||||
{
|
||||
if (context.Request?.ClientId is not Constants.OAuthClientIds.BackOffice)
|
||||
{
|
||||
// Only ever handle the back-office client.
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
if (context.Response.Code is not null)
|
||||
{
|
||||
SetCookie(GetHttpContext(), PkceCodeCookieKey, context.Response.Code);
|
||||
context.Response.Code = RedactedTokenValue;
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This is invoked when requesting new tokens.
|
||||
/// </summary>
|
||||
public ValueTask HandleAsync(OpenIddictServerEvents.ExtractTokenRequestContext context)
|
||||
{
|
||||
if (context.Request?.ClientId != Constants.OAuthClientIds.BackOffice)
|
||||
{
|
||||
// Only ever handle the back-office client.
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
// Handle when the PKCE code is being exchanged for an access token.
|
||||
if (context.Request.Code == RedactedTokenValue
|
||||
&& TryGetCookie(PkceCodeCookieKey, out var code))
|
||||
{
|
||||
context.Request.Code = code;
|
||||
|
||||
// We won't need the PKCE cookie after this, let's remove it.
|
||||
RemoveCookie(GetHttpContext(), PkceCodeCookieKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
// PCKE codes should always be redacted. If we got here, someone might be trying to pass another PKCE
|
||||
// code. For security reasons, explicitly discard the code (if any) to be on the safe side.
|
||||
context.Request.Code = null;
|
||||
}
|
||||
|
||||
// Handle when a refresh token is being exchanged for a new access token.
|
||||
if (context.Request.RefreshToken == RedactedTokenValue
|
||||
&& TryGetCookie(RefreshTokenCookieKey, out var refreshToken))
|
||||
{
|
||||
context.Request.RefreshToken = refreshToken;
|
||||
}
|
||||
else
|
||||
{
|
||||
// If we got here, either the refresh token was not redacted, or nothing was found in the refresh token cookie.
|
||||
// If OpenIddict found a refresh token, it could be an old token that is potentially still valid. For security
|
||||
// reasons, we cannot accept that; at this point, we expect the refresh tokens to be explicitly redacted.
|
||||
context.Request.RefreshToken = null;
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This is invoked when extracting the auth context for a client request.
|
||||
/// </summary>
|
||||
public ValueTask HandleAsync(OpenIddictValidationEvents.ProcessAuthenticationContext context)
|
||||
{
|
||||
// For the back-office client, this only happens when an access token is sent to the API.
|
||||
if (context.AccessToken != RedactedTokenValue)
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
if (TryGetCookie(AccessTokenCookieKey, out var accessToken))
|
||||
{
|
||||
context.AccessToken = accessToken;
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public void Handle(UserLogoutSuccessNotification notification)
|
||||
{
|
||||
HttpContext? context = _httpContextAccessor.HttpContext;
|
||||
if (context is null)
|
||||
{
|
||||
// For some reason there is no ambient HTTP context, so we can't clean up the cookies.
|
||||
// This is OK, because the tokens in the cookies have already been revoked at user sign-out,
|
||||
// so the cookie clean-up is mostly cosmetic.
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.Cookies.Delete(AccessTokenCookieKey);
|
||||
context.Response.Cookies.Delete(RefreshTokenCookieKey);
|
||||
}
|
||||
|
||||
private HttpContext GetHttpContext()
|
||||
=> _httpContextAccessor.GetRequiredHttpContext();
|
||||
|
||||
private void SetCookie(HttpContext httpContext, string key, string value)
|
||||
{
|
||||
var cookieValue = EncryptionHelper.Encrypt(value, _dataProtectionProvider);
|
||||
|
||||
RemoveCookie(httpContext, key);
|
||||
httpContext.Response.Cookies.Append(key, cookieValue, GetCookieOptions(httpContext));
|
||||
}
|
||||
|
||||
private void RemoveCookie(HttpContext httpContext, string key)
|
||||
=> httpContext.Response.Cookies.Delete(key, GetCookieOptions(httpContext));
|
||||
|
||||
private CookieOptions GetCookieOptions(HttpContext httpContext) =>
|
||||
new()
|
||||
{
|
||||
// Prevent the client-side scripts from accessing the cookie.
|
||||
HttpOnly = true,
|
||||
|
||||
// Mark the cookie as essential to the application, to enforce it despite any
|
||||
// data collection consent options. This aligns with how ASP.NET Core Identity
|
||||
// does when writing cookies for cookie authentication.
|
||||
IsEssential = true,
|
||||
|
||||
// Cookie path must be root for optimal security.
|
||||
Path = "/",
|
||||
|
||||
// For optimal security, the cooke must be secure. However, Umbraco allows for running development
|
||||
// environments over HTTP, so we need to take that into account here.
|
||||
// Thus, we will make the cookie secure if:
|
||||
// - HTTPS is explicitly enabled by config (default for production environments), or
|
||||
// - The current request is over HTTPS (meaning the environment supports it regardless of config).
|
||||
Secure = _globalSettings.UseHttps || httpContext.Request.IsHttps,
|
||||
|
||||
// SameSite is configurable (see BackOfficeTokenCookieSettings for defaults):
|
||||
SameSite = ParseSameSiteMode(_backOfficeTokenCookieSettings.SameSite),
|
||||
};
|
||||
|
||||
private bool TryGetCookie(string key, [NotNullWhen(true)] out string? value)
|
||||
{
|
||||
if (GetHttpContext().Request.Cookies.TryGetValue(key, out var cookieValue))
|
||||
{
|
||||
value = EncryptionHelper.Decrypt(cookieValue, _dataProtectionProvider);
|
||||
return true;
|
||||
}
|
||||
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static SameSiteMode ParseSameSiteMode(string sameSiteMode) =>
|
||||
Enum.TryParse(sameSiteMode, ignoreCase: true, out SameSiteMode result)
|
||||
? result
|
||||
: throw new ArgumentException($"The provided {nameof(sameSiteMode)} value could not be parsed into as SameSiteMode value.", nameof(sameSiteMode));
|
||||
}
|
||||
@@ -9,6 +9,7 @@ using Umbraco.Cms.Api.Common.Security;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
@@ -28,6 +29,11 @@ public static class UmbracoBuilderAuthExtensions
|
||||
|
||||
private static void ConfigureOpenIddict(IUmbracoBuilder builder)
|
||||
{
|
||||
// Optionally hide tokens from the back-office.
|
||||
var hideBackOfficeTokens = (builder.Config
|
||||
.GetSection(Constants.Configuration.ConfigBackOfficeTokenCookie)
|
||||
.Get<BackOfficeTokenCookieSettings>() ?? new BackOfficeTokenCookieSettings()).Enabled;
|
||||
|
||||
builder.Services.AddOpenIddict()
|
||||
// Register the OpenIddict server components.
|
||||
.AddServer(options =>
|
||||
@@ -113,6 +119,28 @@ public static class UmbracoBuilderAuthExtensions
|
||||
{
|
||||
configuration.UseSingletonHandler<ProcessRequestContextHandler>().SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.ResolveRequestUri.Descriptor.Order - 1);
|
||||
});
|
||||
|
||||
if (hideBackOfficeTokens)
|
||||
{
|
||||
options.AddEventHandler<OpenIddictServerEvents.ApplyTokenResponseContext>(configuration =>
|
||||
{
|
||||
configuration
|
||||
.UseSingletonHandler<HideBackOfficeTokensHandler>()
|
||||
.SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.ProcessJsonResponse<OpenIddictServerEvents.ApplyTokenResponseContext>.Descriptor.Order - 1);
|
||||
});
|
||||
options.AddEventHandler<OpenIddictServerEvents.ApplyAuthorizationResponseContext>(configuration =>
|
||||
{
|
||||
configuration
|
||||
.UseSingletonHandler<HideBackOfficeTokensHandler>()
|
||||
.SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.Authentication.ProcessQueryResponse.Descriptor.Order - 1);
|
||||
});
|
||||
options.AddEventHandler<OpenIddictServerEvents.ExtractTokenRequestContext>(configuration =>
|
||||
{
|
||||
configuration
|
||||
.UseSingletonHandler<HideBackOfficeTokensHandler>()
|
||||
.SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.ExtractPostRequest<OpenIddictServerEvents.ExtractTokenRequestContext>.Descriptor.Order + 1);
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
// Register the OpenIddict validation components.
|
||||
@@ -137,9 +165,25 @@ public static class UmbracoBuilderAuthExtensions
|
||||
{
|
||||
configuration.UseSingletonHandler<ProcessRequestContextHandler>().SetOrder(OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers.ResolveRequestUri.Descriptor.Order - 1);
|
||||
});
|
||||
|
||||
if (hideBackOfficeTokens)
|
||||
{
|
||||
options.AddEventHandler<OpenIddictValidationEvents.ProcessAuthenticationContext>(configuration =>
|
||||
{
|
||||
configuration
|
||||
.UseSingletonHandler<HideBackOfficeTokensHandler>()
|
||||
// IMPORTANT: the handler must be AFTER the built-in query string handler, because the client-side SignalR library sometimes appends access tokens to the query string.
|
||||
.SetOrder(OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers.ExtractAccessTokenFromQueryString.Descriptor.Order + 1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
builder.Services.AddRecurringBackgroundJob<OpenIddictCleanupJob>();
|
||||
builder.Services.ConfigureOptions<ConfigureOpenIddict>();
|
||||
|
||||
if (hideBackOfficeTokens)
|
||||
{
|
||||
builder.AddNotificationHandler<UserLogoutSuccessNotification, HideBackOfficeTokensHandler>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,60 @@
|
||||
using Asp.Versioning;
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Document;
|
||||
using Umbraco.Cms.Core.Actions;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Mapping;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Security.Authorization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Web.Common.Authorization;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.Document;
|
||||
|
||||
[ApiVersion("1.0")]
|
||||
public class DomainsController : DocumentControllerBase
|
||||
{
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IDomainService _domainService;
|
||||
private readonly IUmbracoMapper _umbracoMapper;
|
||||
|
||||
public DomainsController(IDomainService domainService, IUmbracoMapper umbracoMapper)
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public DomainsController(IAuthorizationService authorizationService, IDomainService domainService, IUmbracoMapper umbracoMapper)
|
||||
{
|
||||
_authorizationService = authorizationService;
|
||||
_domainService = domainService;
|
||||
_umbracoMapper = umbracoMapper;
|
||||
}
|
||||
|
||||
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
|
||||
public DomainsController(IDomainService domainService, IUmbracoMapper umbracoMapper)
|
||||
: this(
|
||||
StaticServiceProvider.Instance.GetRequiredService<IAuthorizationService>(),
|
||||
domainService,
|
||||
umbracoMapper)
|
||||
{
|
||||
}
|
||||
|
||||
[MapToApiVersion("1.0")]
|
||||
[HttpGet("{id:guid}/domains")]
|
||||
[ProducesResponseType(typeof(DomainsResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Domains(CancellationToken cancellationToken, Guid id)
|
||||
{
|
||||
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
|
||||
User,
|
||||
ContentPermissionResource.WithKeys(ActionBrowse.ActionLetter, id),
|
||||
AuthorizationPolicies.ContentPermissionByResource);
|
||||
|
||||
if (!authorizationResult.Succeeded)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
IDomain[] assignedDomains = (await _domainService.GetAssignedDomainsAsync(id, true))
|
||||
.OrderBy(d => d.SortOrder)
|
||||
.ToArray();
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
using Asp.Versioning;
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Api.Management.Factories;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Document;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Actions;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Mapping;
|
||||
using Umbraco.Cms.Core.Models.ContentEditing;
|
||||
using Umbraco.Cms.Core.Security.Authorization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
using Umbraco.Cms.Web.Common.Authorization;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.Document;
|
||||
@@ -15,17 +21,30 @@ namespace Umbraco.Cms.Api.Management.Controllers.Document;
|
||||
[ApiVersion("1.0")]
|
||||
public class UpdateDomainsController : DocumentControllerBase
|
||||
{
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IDomainService _domainService;
|
||||
private readonly IUmbracoMapper _umbracoMapper;
|
||||
private readonly IDomainPresentationFactory _domainPresentationFactory;
|
||||
|
||||
public UpdateDomainsController(IDomainService domainService, IUmbracoMapper umbracoMapper, IDomainPresentationFactory domainPresentationFactory)
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public UpdateDomainsController(IAuthorizationService authorizationService, IDomainService domainService, IUmbracoMapper umbracoMapper, IDomainPresentationFactory domainPresentationFactory)
|
||||
{
|
||||
_authorizationService = authorizationService;
|
||||
_domainService = domainService;
|
||||
_umbracoMapper = umbracoMapper;
|
||||
_domainPresentationFactory = domainPresentationFactory;
|
||||
}
|
||||
|
||||
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
|
||||
public UpdateDomainsController(IDomainService domainService, IUmbracoMapper umbracoMapper, IDomainPresentationFactory domainPresentationFactory)
|
||||
: this(
|
||||
StaticServiceProvider.Instance.GetRequiredService<IAuthorizationService>(),
|
||||
domainService,
|
||||
umbracoMapper,
|
||||
domainPresentationFactory)
|
||||
{
|
||||
}
|
||||
|
||||
[MapToApiVersion("1.0")]
|
||||
[HttpPut("{id:guid}/domains")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
@@ -37,6 +56,16 @@ public class UpdateDomainsController : DocumentControllerBase
|
||||
Guid id,
|
||||
UpdateDomainsRequestModel updateModel)
|
||||
{
|
||||
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
|
||||
User,
|
||||
ContentPermissionResource.WithKeys(ActionAssignDomain.ActionLetter, id),
|
||||
AuthorizationPolicies.ContentPermissionByResource);
|
||||
|
||||
if (!authorizationResult.Succeeded)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
DomainsUpdateModel domainsUpdateModel = _umbracoMapper.Map<DomainsUpdateModel>(updateModel)!;
|
||||
|
||||
Attempt<DomainUpdateResult, DomainOperationStatus> result = await _domainService.UpdateDomainsAsync(id, domainsUpdateModel);
|
||||
|
||||
+31
-1
@@ -1,33 +1,63 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Document;
|
||||
using Umbraco.Cms.Core.Actions;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Security.Authorization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Web.Common.Authorization;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.Document;
|
||||
|
||||
[ApiVersion("1.0")]
|
||||
public class UpdateNotificationsController : DocumentControllerBase
|
||||
{
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IContentEditingService _contentEditingService;
|
||||
private readonly INotificationService _notificationService;
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
|
||||
public UpdateNotificationsController(IContentEditingService contentEditingService, INotificationService notificationService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public UpdateNotificationsController(IAuthorizationService authorizationService, IContentEditingService contentEditingService, INotificationService notificationService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
|
||||
{
|
||||
_authorizationService = authorizationService;
|
||||
_contentEditingService = contentEditingService;
|
||||
_notificationService = notificationService;
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
}
|
||||
|
||||
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
|
||||
public UpdateNotificationsController(IContentEditingService contentEditingService, INotificationService notificationService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
|
||||
: this(
|
||||
StaticServiceProvider.Instance.GetRequiredService<IAuthorizationService>(),
|
||||
contentEditingService,
|
||||
notificationService,
|
||||
backOfficeSecurityAccessor)
|
||||
{
|
||||
}
|
||||
|
||||
[MapToApiVersion("1.0")]
|
||||
[HttpPut("{id:guid}/notifications")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> UpdateNotifications(CancellationToken cancellationToken, Guid id, UpdateDocumentNotificationsRequestModel updateModel)
|
||||
{
|
||||
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
|
||||
User,
|
||||
ContentPermissionResource.WithKeys(ActionBrowse.ActionLetter, id),
|
||||
AuthorizationPolicies.ContentPermissionByResource);
|
||||
|
||||
if (!authorizationResult.Succeeded)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
IContent? content = await _contentEditingService.GetAsync(id);
|
||||
if (content == null)
|
||||
{
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
using Asp.Versioning;
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Api.Management.Controllers.UserGroup;
|
||||
using Umbraco.Cms.Api.Management.Routing;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.User;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Security.Authorization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
@@ -25,11 +28,26 @@ public class UpdateUserGroupsUserController : UserGroupControllerBase
|
||||
{
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IUserGroupService _userGroupService;
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
|
||||
public UpdateUserGroupsUserController(IAuthorizationService authorizationService, IUserGroupService userGroupService)
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public UpdateUserGroupsUserController(
|
||||
IAuthorizationService authorizationService,
|
||||
IUserGroupService userGroupService,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
|
||||
{
|
||||
_authorizationService = authorizationService;
|
||||
_userGroupService = userGroupService;
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
}
|
||||
|
||||
[Obsolete("Please use the constructor accepting all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
public UpdateUserGroupsUserController(IAuthorizationService authorizationService, IUserGroupService userGroupService)
|
||||
: this(
|
||||
authorizationService,
|
||||
userGroupService,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IBackOfficeSecurityAccessor>())
|
||||
{
|
||||
}
|
||||
|
||||
[HttpPost("set-user-groups")]
|
||||
@@ -51,7 +69,8 @@ public class UpdateUserGroupsUserController : UserGroupControllerBase
|
||||
|
||||
Attempt<UserGroupOperationStatus> result = await _userGroupService.UpdateUserGroupsOnUsersAsync(
|
||||
requestModel.UserGroupIds.Select(x => x.Id).ToHashSet(),
|
||||
requestModel.UserIds.Select(x => x.Id).ToHashSet());
|
||||
requestModel.UserIds.Select(x => x.Id).ToHashSet(),
|
||||
CurrentUserKey(_backOfficeSecurityAccessor));
|
||||
|
||||
return result.Success
|
||||
? Ok()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Umbraco.Cms.Api.Common.Accessors;
|
||||
using Umbraco.Cms.Api.Common.Rendering;
|
||||
using Umbraco.Cms.Api.Management.Factories;
|
||||
@@ -16,9 +16,10 @@ internal static class WebhooksBuilderExtensions
|
||||
builder.Services.AddUnique<IWebhookPresentationFactory, WebhookPresentationFactory>();
|
||||
builder.AddMapDefinition<WebhookEventMapDefinition>();
|
||||
|
||||
// deliveryApi will overwrite these more basic ones.
|
||||
builder.Services.AddScoped<IOutputExpansionStrategy, ElementOnlyOutputExpansionStrategy>();
|
||||
builder.Services.AddSingleton<IOutputExpansionStrategyAccessor, RequestContextOutputExpansionStrategyAccessor>();
|
||||
// We have to use TryAdd here, as if they are registered by the delivery API, we don't want to register them
|
||||
// Delivery API will also overwrite these IF it is enabled.
|
||||
builder.Services.TryAddScoped<IOutputExpansionStrategy, ElementOnlyOutputExpansionStrategy>();
|
||||
builder.Services.TryAddSingleton<IOutputExpansionStrategyAccessor, RequestContextOutputExpansionStrategyAccessor>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace Umbraco.Cms.Core.Configuration.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Typed configuration options for back-office token cookie settings.
|
||||
/// </summary>
|
||||
[UmbracoOptions(Constants.Configuration.ConfigBackOfficeTokenCookie)]
|
||||
[Obsolete("This will be replaced with a different authentication scheme. Scheduled for removal in Umbraco 18.")]
|
||||
public class BackOfficeTokenCookieSettings
|
||||
{
|
||||
private const bool StaticEnabled = false;
|
||||
|
||||
private const string StaticSameSite = "Strict";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to enable access and refresh tokens in cookies.
|
||||
/// </summary>
|
||||
[DefaultValue(StaticEnabled)]
|
||||
[Obsolete("This is only configurable in Umbraco 16. Scheduled for removal in Umbraco 17.")]
|
||||
public bool Enabled { get; set; } = StaticEnabled;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the cookie SameSite configuration.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Valid values are "Unspecified", "None", "Lax" and "Strict" (default).
|
||||
/// </remarks>
|
||||
[DefaultValue(StaticSameSite)]
|
||||
public string SameSite { get; set; } = StaticSameSite;
|
||||
}
|
||||
@@ -64,6 +64,7 @@ public static partial class Constants
|
||||
public const string ConfigWebhook = ConfigPrefix + "Webhook";
|
||||
public const string ConfigWebhookPayloadType = ConfigWebhook + ":PayloadType";
|
||||
public const string ConfigCache = ConfigPrefix + "Cache";
|
||||
public const string ConfigBackOfficeTokenCookie = ConfigSecurity + ":BackOfficeTokenCookie";
|
||||
|
||||
public static class NamedOptions
|
||||
{
|
||||
|
||||
@@ -86,7 +86,8 @@ public static partial class UmbracoBuilderExtensions
|
||||
.AddUmbracoOptions<HelpPageSettings>()
|
||||
.AddUmbracoOptions<DataTypesSettings>()
|
||||
.AddUmbracoOptions<WebhookSettings>()
|
||||
.AddUmbracoOptions<CacheSettings>();
|
||||
.AddUmbracoOptions<CacheSettings>()
|
||||
.AddUmbracoOptions<BackOfficeTokenCookieSettings>();
|
||||
|
||||
// Configure connection string and ensure it's updated when the configuration changes
|
||||
builder.Services.AddSingleton<IConfigureOptions<ConnectionStrings>, ConfigureConnectionStrings>();
|
||||
|
||||
@@ -81,46 +81,23 @@ public class UserEditorAuthorizationHelper
|
||||
return Attempt<string?>.Succeed();
|
||||
}
|
||||
|
||||
// d) a non-admin user can remove any groups but can only add groups they themselves belong to
|
||||
if (userGroupAliases != null)
|
||||
{
|
||||
var savingGroupAliases = userGroupAliases.ToArray();
|
||||
var existingGroupAliases = savingUser == null
|
||||
IEnumerable<string> requestedGroupAliases = userGroupAliases.ToArray();
|
||||
IEnumerable<string> existingGroupAliases = savingUser == null
|
||||
? []
|
||||
: savingUser.Groups.Select(x => x.Alias).ToArray();
|
||||
: savingUser.Groups.Select(x => x.Alias);
|
||||
IEnumerable<string> performingUserGroupAliases = currentUser?.Groups.Select(x => x.Alias) ?? Enumerable.Empty<string>();
|
||||
|
||||
IEnumerable<string> addedGroupAliases = savingGroupAliases.Except(existingGroupAliases);
|
||||
IReadOnlyList<string> unauthorized = UserGroupAssignmentAuthorization
|
||||
.GetUnauthorizedGroupAssignments(performingUserGroupAliases, requestedGroupAliases, existingGroupAliases);
|
||||
|
||||
// As we know the current user is not admin, it is only allowed to use groups that the user do have themselves.
|
||||
var savingGroupAliasesNotAllowed = addedGroupAliases
|
||||
.Except(currentUser?.Groups.Select(x => x.Alias) ?? Enumerable.Empty<string>()).ToArray();
|
||||
if (savingGroupAliasesNotAllowed.Any())
|
||||
if (unauthorized.Count > 0)
|
||||
{
|
||||
return Attempt.Fail("Cannot assign the group(s) '" + string.Join(", ", savingGroupAliasesNotAllowed) +
|
||||
return Attempt.Fail("Cannot assign the group(s) '" + string.Join(", ", unauthorized) +
|
||||
"', the current user is not part of them or admin");
|
||||
}
|
||||
|
||||
// only validate any groups that have changed.
|
||||
// a non-admin user can remove groups and add groups that they have access to
|
||||
// but they cannot add a group that they do not have access to or that grants them
|
||||
// path or section access that they don't have access to.
|
||||
var newGroups = savingUser == null
|
||||
? savingGroupAliases
|
||||
: savingGroupAliases.Except(savingUser.Groups.Select(x => x.Alias)).ToArray();
|
||||
|
||||
var userGroupsChanged = savingUser != null && newGroups.Length > 0;
|
||||
|
||||
if (userGroupsChanged)
|
||||
{
|
||||
// d) A user cannot assign a group to another user that they do not belong to
|
||||
var currentUserGroups = currentUser?.Groups.Select(x => x.Alias).ToArray();
|
||||
foreach (var group in newGroups)
|
||||
{
|
||||
if (currentUserGroups?.Contains(group) == false)
|
||||
{
|
||||
return Attempt.Fail("Cannot assign the group " + group + ", the current user is not a member");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Attempt<string?>.Succeed();
|
||||
|
||||
@@ -12,7 +12,7 @@ public class X : OEmbedProviderBase
|
||||
{
|
||||
}
|
||||
|
||||
public override string ApiEndpoint => "http://publish.twitter.com/oembed";
|
||||
public override string ApiEndpoint => "https://publish.x.com/oembed";
|
||||
|
||||
public override string[] UrlSchemeRegex => new[] { @"(https?:\/\/(www\.)?)(twitter|x)\.com\/.*\/status\/.*" };
|
||||
|
||||
|
||||
@@ -23,4 +23,11 @@ public class RichTextBlockValue : BlockValue<RichTextBlockLayoutItem>
|
||||
/// <inheritdoc />
|
||||
[JsonIgnore]
|
||||
public override string PropertyEditorAlias => Constants.PropertyEditors.Aliases.RichText;
|
||||
|
||||
/// <inheritdoc />
|
||||
#pragma warning disable CS0672 // Member overrides obsolete member
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
public override bool SupportsBlockLayoutAlias(string alias) => base.SupportsBlockLayoutAlias(alias) || alias.Equals("Umbraco.TinyMCE");
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
#pragma warning restore CS0672 // Member overrides obsolete member
|
||||
}
|
||||
|
||||
@@ -53,6 +53,12 @@ public interface IPublishedContentTypeFactory
|
||||
/// </summary>
|
||||
PublishedDataType GetDataType(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Clears the internal data type cache.
|
||||
/// </summary>
|
||||
void ClearDataTypeCache()
|
||||
{ }
|
||||
|
||||
/// <summary>
|
||||
/// Notifies the factory of datatype changes.
|
||||
/// </summary>
|
||||
|
||||
@@ -65,6 +65,22 @@ public class PublishedContentTypeFactory : IPublishedContentTypeFactory
|
||||
return dataType;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ClearDataTypeCache()
|
||||
{
|
||||
if (_publishedDataTypes is null)
|
||||
{
|
||||
// Not initialized yet, so skip and avoid lock
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_publishedDataTypesLocker)
|
||||
{
|
||||
// Clear cache (and let it lazy initialize again later)
|
||||
_publishedDataTypes = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void NotifyDataTypeChanges(params int[] ids)
|
||||
{
|
||||
|
||||
@@ -3,12 +3,14 @@ using System.Globalization;
|
||||
using System.Runtime.Serialization;
|
||||
using System.Xml.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.IO;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Editors;
|
||||
using Umbraco.Cms.Core.Models.Validation;
|
||||
using Umbraco.Cms.Core.PropertyEditors.Validators;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Strings;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
@@ -20,6 +22,9 @@ namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
[DataContract]
|
||||
public class DataValueEditor : IDataValueEditor
|
||||
{
|
||||
private const string ContentCacheKeyFormat = nameof(DataValueEditor) + "_Content_{0}";
|
||||
private const string MediaCacheKeyFormat = nameof(DataValueEditor) + "_Media_{0}";
|
||||
|
||||
private readonly IJsonSerializer? _jsonSerializer;
|
||||
private readonly IShortStringHelper _shortStringHelper;
|
||||
|
||||
@@ -415,4 +420,155 @@ public class DataValueEditor : IDataValueEditor
|
||||
|
||||
return value.TryConvertTo(valueType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a <see cref="IContent"/> instance by its unique identifier, using the provided request cache to avoid redundant
|
||||
/// lookups within the same request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method caches content lookups for the duration of the current request to improve performance when the same content
|
||||
/// item may be accessed multiple times. This is particularly useful in scenarios involving multiple languages or blocks.
|
||||
/// </remarks>
|
||||
/// <param name="key">The unique identifier of the content item to retrieve.</param>
|
||||
/// <param name="requestCache">The request-scoped cache used to store and retrieve content items for the duration of the current request.</param>
|
||||
/// <param name="contentService">The content service used to fetch the content item if it is not found in the cache.</param>
|
||||
/// <returns>The <see cref="IContent"/> instance corresponding to the specified key, or null if no such content item exists.</returns>
|
||||
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
|
||||
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
|
||||
"Scheduled for removal in Umbraco 19.")]
|
||||
protected static IContent? GetAndCacheContentById(Guid key, IRequestCache requestCache, IContentService contentService)
|
||||
{
|
||||
if (requestCache.IsAvailable is false)
|
||||
{
|
||||
return contentService.GetById(key);
|
||||
}
|
||||
|
||||
var cacheKey = string.Format(ContentCacheKeyFormat, key);
|
||||
IContent? content = requestCache.GetCacheItem<IContent?>(cacheKey);
|
||||
if (content is null)
|
||||
{
|
||||
content = contentService.GetById(key);
|
||||
if (content is not null)
|
||||
{
|
||||
requestCache.Set(cacheKey, content);
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the specified <see cref="IContent"/> item to the request cache using its unique key.
|
||||
/// </summary>
|
||||
/// <param name="content">The content item to cache.</param>
|
||||
/// <param name="requestCache">The request cache in which to store the content item.</param>
|
||||
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
|
||||
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
|
||||
"Scheduled for removal in Umbraco 19.")]
|
||||
protected static void CacheContentById(IContent content, IRequestCache requestCache)
|
||||
{
|
||||
if (requestCache.IsAvailable is false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cacheKey = string.Format(ContentCacheKeyFormat, content.Key);
|
||||
requestCache.Set(cacheKey, content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a <see cref="IMedia"/> instance by its unique identifier, using the provided request cache to avoid redundant
|
||||
/// lookups within the same request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method caches media lookups for the duration of the current request to improve performance when the same media
|
||||
/// item may be accessed multiple times. This is particularly useful in scenarios involving multiple languages or blocks.
|
||||
/// </remarks>
|
||||
/// <param name="key">The unique identifier of the media item to retrieve.</param>
|
||||
/// <param name="requestCache">The request-scoped cache used to store and retrieve media items for the duration of the current request.</param>
|
||||
/// <param name="mediaService">The media service used to fetch the media item if it is not found in the cache.</param>
|
||||
/// <returns>The <see cref="IMedia"/> instance corresponding to the specified key, or null if no such media item exists.</returns>
|
||||
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
|
||||
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
|
||||
"Scheduled for removal in Umbraco 19.")]
|
||||
protected static IMedia? GetAndCacheMediaById(Guid key, IRequestCache requestCache, IMediaService mediaService)
|
||||
{
|
||||
if (requestCache.IsAvailable is false)
|
||||
{
|
||||
return mediaService.GetById(key);
|
||||
}
|
||||
|
||||
var cacheKey = string.Format(MediaCacheKeyFormat, key);
|
||||
IMedia? media = requestCache.GetCacheItem<IMedia?>(cacheKey);
|
||||
|
||||
if (media is null)
|
||||
{
|
||||
media = mediaService.GetById(key);
|
||||
if (media is not null)
|
||||
{
|
||||
requestCache.Set(cacheKey, media);
|
||||
}
|
||||
}
|
||||
|
||||
return media;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the specified <see cref="IMedia"/> item to the request cache using its unique key.
|
||||
/// </summary>
|
||||
/// <param name="media">The media item to cache.</param>
|
||||
/// <param name="requestCache">The request cache in which to store the media item.</param>
|
||||
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
|
||||
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
|
||||
"Scheduled for removal in Umbraco 19.")]
|
||||
protected static void CacheMediaById(IMedia media, IRequestCache requestCache)
|
||||
{
|
||||
if (requestCache.IsAvailable is false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cacheKey = string.Format(MediaCacheKeyFormat, media.Key);
|
||||
requestCache.Set(cacheKey, media);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the content item identified by the specified key is present in the request cache.
|
||||
/// </summary>
|
||||
/// <param name="key">The unique identifier for the content item to check for in the cache.</param>
|
||||
/// <param name="requestCache">The request cache in which to look for the content item.</param>
|
||||
/// <returns>true if the content item is already cached in the request cache; otherwise, false.</returns>
|
||||
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
|
||||
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
|
||||
"Scheduled for removal in Umbraco 19.")]
|
||||
protected static bool IsContentAlreadyCached(Guid key, IRequestCache requestCache)
|
||||
{
|
||||
if (requestCache.IsAvailable is false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var cacheKey = string.Format(ContentCacheKeyFormat, key);
|
||||
return requestCache.GetCacheItem<IContent?>(cacheKey) is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the media item identified by the specified key is present in the request cache.
|
||||
/// </summary>
|
||||
/// <param name="key">The unique identifier for the media item to check for in the cache.</param>
|
||||
/// <param name="requestCache">The request cache in which to look for the media item.</param>
|
||||
/// <returns>true if the media item is already cached in the request cache; otherwise, false.</returns>
|
||||
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
|
||||
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
|
||||
"Scheduled for removal in Umbraco 19.")]
|
||||
protected static bool IsMediaAlreadyCached(Guid key, IRequestCache requestCache)
|
||||
{
|
||||
if (requestCache.IsAvailable is false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var cacheKey = string.Format(MediaCacheKeyFormat, key);
|
||||
return requestCache.GetCacheItem<IMedia?>(cacheKey) is not null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
|
||||
/// <summary>
|
||||
/// Optionally implemented by property editors, this defines a contract for caching entities that are referenced in block values.
|
||||
/// </summary>
|
||||
[Obsolete("This interface is available for support of request caching retrieved entities in property value editors that implement it. " +
|
||||
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
|
||||
"Scheduled for removal in Umbraco 19.")]
|
||||
public interface ICacheReferencedEntities
|
||||
{
|
||||
/// <summary>
|
||||
/// Caches the entities referenced by the provided block data values.
|
||||
/// </summary>
|
||||
/// <param name="values">An enumerable collection of block values that may contain the entities to be cached.</param>
|
||||
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
|
||||
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
|
||||
"Scheduled for removal in Umbraco 19.")]
|
||||
void CacheReferencedEntities(IEnumerable<object> values);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace Umbraco.Cms.Core.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Shared authorization logic for user group assignment.
|
||||
/// </summary>
|
||||
public static class UserGroupAssignmentAuthorization
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the group aliases that the performing user is not authorized to assign.
|
||||
/// </summary>
|
||||
/// <param name="performingUserGroupAliases">The group aliases the performing user belongs to.</param>
|
||||
/// <param name="requestedGroupAliases">The group aliases being assigned to the target user.</param>
|
||||
/// <param name="existingGroupAliases">The group aliases the target user currently belongs to.</param>
|
||||
/// <returns>
|
||||
/// Group aliases that are being added but the performing user does not belong to.
|
||||
/// An empty collection means the assignment is authorized.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// Non-admin users can remove any groups but can only add groups they themselves belong to.
|
||||
/// Callers should check for admin status before calling this method, as admins bypass this check.
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<string> GetUnauthorizedGroupAssignments(
|
||||
IEnumerable<string> performingUserGroupAliases,
|
||||
IEnumerable<string> requestedGroupAliases,
|
||||
IEnumerable<string> existingGroupAliases)
|
||||
{
|
||||
var performingGroups = performingUserGroupAliases.ToHashSet(StringComparer.InvariantCultureIgnoreCase);
|
||||
var existingGroups = existingGroupAliases.ToHashSet(StringComparer.InvariantCultureIgnoreCase);
|
||||
|
||||
return requestedGroupAliases
|
||||
.Where(alias => existingGroups.Contains(alias) is false && performingGroups.Contains(alias) is false)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -175,7 +175,7 @@ internal sealed class MediaTypeEditingService : ContentTypeEditingServiceBase<IM
|
||||
continue;
|
||||
}
|
||||
|
||||
allowedFileExtensionsByMediaType[mediaType] = fileUploadConfiguration.FileExtensions;
|
||||
allowedFileExtensionsByMediaType[mediaType] = fileUploadConfiguration.FileExtensions ?? []; // Although we never expect null here, legacy data type configuration did allow it.
|
||||
}
|
||||
|
||||
return allowedFileExtensionsByMediaType;
|
||||
|
||||
@@ -2,7 +2,22 @@ using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
|
||||
namespace Umbraco.Cms.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a service for asynchronously retrieving embeddable HTML markup for a specified resource using the oEmbed
|
||||
/// protocol.
|
||||
/// </summary>
|
||||
public interface IOEmbedService
|
||||
{
|
||||
/// <summary>
|
||||
/// Asynchronously retrieves the embeddable HTML markup for the specified resource.
|
||||
/// </summary>
|
||||
/// <remarks>The returned markup is suitable for embedding in web pages. The width and height parameters
|
||||
/// may be ignored by some providers depending on their capabilities.</remarks>
|
||||
/// <param name="url">The URI of the resource to retrieve markup for. Must be a valid, absolute URI.</param>
|
||||
/// <param name="width">The optional maximum width, in pixels, for the embedded content. If null, the default width is used.</param>
|
||||
/// <param name="height">The optional maximum height, in pixels, for the embedded content. If null, the default height is used.</param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests. The operation is canceled if the token is triggered.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The result contains an Attempt with the HTML markup if
|
||||
/// successful, or an oEmbed operation status indicating the reason for failure.</returns>
|
||||
Task<Attempt<string, OEmbedOperationStatus>> GetMarkupAsync(Uri url, int? width, int? height, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Membership;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
|
||||
@@ -107,8 +107,25 @@ public interface IUserGroupService
|
||||
/// <param name="userGroupKeys">The user groups the users should be part of.</param>
|
||||
/// <param name="userKeys">The user whose groups we want to alter.</param>
|
||||
/// <returns>An attempt indicating if the operation was a success as well as a more detailed <see cref="UserGroupOperationStatus"/>.</returns>
|
||||
[Obsolete("Please use the overload accepting all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
Task<Attempt<UserGroupOperationStatus>> UpdateUserGroupsOnUsersAsync(ISet<Guid> userGroupKeys, ISet<Guid> userKeys);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the users to have the groups specified, with authorization checks based on the performing user.
|
||||
/// </summary>
|
||||
/// <param name="userGroupKeys">The user groups the users should be part of.</param>
|
||||
/// <param name="userKeys">The user whose groups we want to alter.</param>
|
||||
/// <param name="performingUserKey">The key of the user performing the operation.</param>
|
||||
/// <returns>An attempt indicating if the operation was a success as well as a more detailed <see cref="UserGroupOperationStatus"/>.</returns>
|
||||
/// <remarks>
|
||||
/// Non-admin users can only add groups they themselves belong to. Removing groups is always allowed.
|
||||
/// </remarks>
|
||||
// TODO (V18): Remove default implementation.
|
||||
Task<Attempt<UserGroupOperationStatus>> UpdateUserGroupsOnUsersAsync(ISet<Guid> userGroupKeys, ISet<Guid> userKeys, Guid performingUserKey)
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
=> UpdateUserGroupsOnUsersAsync(userGroupKeys, userKeys);
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
|
||||
Task<Attempt<UserGroupOperationStatus>> AddUsersToUserGroupAsync(UsersToUserGroupManipulationModel addUsersModel, Guid performingUserKey);
|
||||
Task<Attempt<UserGroupOperationStatus>> RemoveUsersFromUserGroupAsync(UsersToUserGroupManipulationModel removeUsersModel, Guid performingUserKey);
|
||||
}
|
||||
|
||||
@@ -6,22 +6,30 @@ using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
|
||||
namespace Umbraco.Cms.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implements <see cref="IOEmbedService"/> for retrieving embeddable HTML markup using the oEmbed protocol.
|
||||
/// </summary>
|
||||
public class OEmbedService : IOEmbedService
|
||||
{
|
||||
private readonly EmbedProvidersCollection _embedProvidersCollection;
|
||||
private readonly ILogger<OEmbedService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="OEmbedService"/> class.
|
||||
/// </summary>
|
||||
public OEmbedService(EmbedProvidersCollection embedProvidersCollection, ILogger<OEmbedService> logger)
|
||||
{
|
||||
_embedProvidersCollection = embedProvidersCollection;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Attempt<string, OEmbedOperationStatus>> GetMarkupAsync(Uri url, int? maxWidth, int? maxHeight, CancellationToken cancellationToken)
|
||||
{
|
||||
// Find the first provider that supports the URL
|
||||
IEmbedProvider? matchedProvider = _embedProvidersCollection
|
||||
.FirstOrDefault(provider => provider.UrlSchemeRegex.Any(regex=>new Regex(regex, RegexOptions.IgnoreCase).IsMatch(url.OriginalString)));
|
||||
.FirstOrDefault(provider => provider.UrlSchemeRegex
|
||||
.Any(regex => new Regex(regex, RegexOptions.IgnoreCase).IsMatch(url.OriginalString)));
|
||||
|
||||
if (matchedProvider is null)
|
||||
{
|
||||
@@ -39,8 +47,8 @@ public class OEmbedService : IOEmbedService
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Unexpected exception happened while trying to get oembed markup. Provider: {Provider}",matchedProvider.GetType().Name);
|
||||
Attempt.FailWithStatus(OEmbedOperationStatus.UnexpectedException, string.Empty, e);
|
||||
_logger.LogError(e, "Unexpected exception happened while trying to get oEmbed markup. Provider: {Provider}", matchedProvider.GetType().Name);
|
||||
return Attempt.FailWithStatus(OEmbedOperationStatus.UnexpectedException, string.Empty, e);
|
||||
}
|
||||
|
||||
return Attempt.FailWithStatus(OEmbedOperationStatus.ProviderReturnedInvalidResult, string.Empty);
|
||||
|
||||
@@ -8,6 +8,7 @@ using Umbraco.Cms.Core.Persistence;
|
||||
using Umbraco.Cms.Core.Persistence.Querying;
|
||||
using Umbraco.Cms.Core.Persistence.Repositories;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Services.AuthorizationStatus;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
using Umbraco.Extensions;
|
||||
@@ -210,9 +211,24 @@ internal sealed class UserGroupService : RepositoryService, IUserGroupService
|
||||
return Attempt.Succeed(UserGroupOperationStatus.Success);
|
||||
}
|
||||
|
||||
public async Task<Attempt<UserGroupOperationStatus>> UpdateUserGroupsOnUsersAsync(
|
||||
// TODO (V19): Collapse the following three methods into a single one, once the obsolete overload
|
||||
// of UpdateUserGroupsOnUsersAsync is removed from the interface.
|
||||
|
||||
public Task<Attempt<UserGroupOperationStatus>> UpdateUserGroupsOnUsersAsync(
|
||||
ISet<Guid> userGroupKeys,
|
||||
ISet<Guid> userKeys)
|
||||
=> UpdateUserGroupsOnUsersInternalAsync(userGroupKeys, userKeys, performingUserKey: null);
|
||||
|
||||
public Task<Attempt<UserGroupOperationStatus>> UpdateUserGroupsOnUsersAsync(
|
||||
ISet<Guid> userGroupKeys,
|
||||
ISet<Guid> userKeys,
|
||||
Guid performingUserKey)
|
||||
=> UpdateUserGroupsOnUsersInternalAsync(userGroupKeys, userKeys, performingUserKey);
|
||||
|
||||
private async Task<Attempt<UserGroupOperationStatus>> UpdateUserGroupsOnUsersInternalAsync(
|
||||
ISet<Guid> userGroupKeys,
|
||||
ISet<Guid> userKeys,
|
||||
Guid? performingUserKey)
|
||||
{
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope();
|
||||
|
||||
@@ -222,6 +238,40 @@ internal sealed class UserGroupService : RepositoryService, IUserGroupService
|
||||
.Select(x => x.ToReadOnlyGroup())
|
||||
.ToArray();
|
||||
|
||||
// Authorize the performing user if provided.
|
||||
if (performingUserKey.HasValue)
|
||||
{
|
||||
IUser? performingUser = await _userService.GetAsync(performingUserKey.Value);
|
||||
if (performingUser is null)
|
||||
{
|
||||
scope.Complete();
|
||||
return Attempt.Fail(UserGroupOperationStatus.MissingUser);
|
||||
}
|
||||
|
||||
if (performingUser.IsAdmin() is false)
|
||||
{
|
||||
string[] performingUserGroupAliases = performingUser.Groups.Select(g => g.Alias).ToArray();
|
||||
string[] requestedGroupAliases = userGroups.Select(g => g.Alias).ToArray();
|
||||
|
||||
foreach (IUser user in users)
|
||||
{
|
||||
IEnumerable<string> existingGroupAliases = user.Groups.Select(g => g.Alias);
|
||||
|
||||
IReadOnlyList<string> unauthorized = UserGroupAssignmentAuthorization
|
||||
.GetUnauthorizedGroupAssignments(performingUserGroupAliases, requestedGroupAliases, existingGroupAliases);
|
||||
|
||||
if (unauthorized.Count > 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"The performing user is not allowed to assign user group(s) '{GroupAliases}' because they do not belong to them.",
|
||||
string.Join(", ", unauthorized));
|
||||
scope.Complete();
|
||||
return Attempt.Fail(UserGroupOperationStatus.Unauthorized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This means that we're potentially de-admining a user, which might cause the admin group to be empty.
|
||||
if (userGroupKeys.Contains(Constants.Security.AdminGroupKey) is false)
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@ using Umbraco.Cms.Core.Configuration.Models;
|
||||
namespace Umbraco.Cms.Infrastructure.Examine.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the index options to construct the Examine indexes
|
||||
/// Configures the index options to construct the Examine indexes.
|
||||
/// </summary>
|
||||
public sealed class ConfigureIndexOptions : IConfigureNamedOptions<LuceneDirectoryIndexOptions>
|
||||
{
|
||||
@@ -18,6 +18,9 @@ public sealed class ConfigureIndexOptions : IConfigureNamedOptions<LuceneDirecto
|
||||
private readonly IUmbracoIndexConfig _umbracoIndexConfig;
|
||||
private readonly IDeliveryApiContentIndexFieldDefinitionBuilder _deliveryApiContentIndexFieldDefinitionBuilder;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ConfigureIndexOptions"/> class.
|
||||
/// </summary>
|
||||
public ConfigureIndexOptions(
|
||||
IUmbracoIndexConfig umbracoIndexConfig,
|
||||
IOptions<IndexCreatorSettings> settings,
|
||||
@@ -28,24 +31,27 @@ public sealed class ConfigureIndexOptions : IConfigureNamedOptions<LuceneDirecto
|
||||
_deliveryApiContentIndexFieldDefinitionBuilder = deliveryApiContentIndexFieldDefinitionBuilder;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Configure(string? name, LuceneDirectoryIndexOptions options)
|
||||
{
|
||||
// When creating FieldDefinitions with Umbraco defaults, pass in any already defined to avoid overwriting
|
||||
// those added via a package or custom code.
|
||||
switch (name)
|
||||
{
|
||||
case Constants.UmbracoIndexes.InternalIndexName:
|
||||
options.Analyzer = new CultureInvariantWhitespaceAnalyzer();
|
||||
options.Validator = _umbracoIndexConfig.GetContentValueSetValidator();
|
||||
options.FieldDefinitions = new UmbracoFieldDefinitionCollection();
|
||||
options.FieldDefinitions = new UmbracoFieldDefinitionCollection(options.FieldDefinitions);
|
||||
break;
|
||||
case Constants.UmbracoIndexes.ExternalIndexName:
|
||||
options.Analyzer = new StandardAnalyzer(LuceneInfo.CurrentVersion);
|
||||
options.Validator = _umbracoIndexConfig.GetPublishedContentValueSetValidator();
|
||||
options.FieldDefinitions = new UmbracoFieldDefinitionCollection();
|
||||
options.FieldDefinitions = new UmbracoFieldDefinitionCollection(options.FieldDefinitions);
|
||||
break;
|
||||
case Constants.UmbracoIndexes.MembersIndexName:
|
||||
options.Analyzer = new CultureInvariantWhitespaceAnalyzer();
|
||||
options.Validator = _umbracoIndexConfig.GetMemberValueSetValidator();
|
||||
options.FieldDefinitions = new UmbracoFieldDefinitionCollection();
|
||||
options.FieldDefinitions = new UmbracoFieldDefinitionCollection(options.FieldDefinitions);
|
||||
break;
|
||||
case Constants.UmbracoIndexes.DeliveryApiContentIndexName:
|
||||
options.Analyzer = new StandardAnalyzer(LuceneInfo.CurrentVersion);
|
||||
@@ -64,6 +70,7 @@ public sealed class ConfigureIndexOptions : IConfigureNamedOptions<LuceneDirecto
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Configure(LuceneDirectoryIndexOptions options)
|
||||
=> throw new NotImplementedException("This is never called and is just part of the interface");
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ public static partial class UmbracoBuilderExtensions
|
||||
|
||||
// Add post migration notification handlers
|
||||
builder.AddNotificationHandler<UmbracoPlanExecutedNotification, ClearCsrfCookieHandler>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,29 @@ public class UmbracoFieldDefinitionCollection : FieldDefinitionCollection
|
||||
new(UmbracoExamineFieldNames.VariesByCultureFieldName, FieldDefinitionTypes.Raw),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UmbracoFieldDefinitionCollection"/> class containing
|
||||
/// the default Umbraco field definitions.
|
||||
/// </summary>
|
||||
public UmbracoFieldDefinitionCollection()
|
||||
: base(UmbracoIndexFieldDefinitions)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UmbracoFieldDefinitionCollection"/> class containing the containing
|
||||
/// the default Umbraco field definitions, augmented or overridden by the provided definitions.
|
||||
/// </summary>
|
||||
/// <param name="definitions">Existing collection of field definitions.</param>
|
||||
public UmbracoFieldDefinitionCollection(FieldDefinitionCollection definitions)
|
||||
: base(UmbracoIndexFieldDefinitions)
|
||||
{
|
||||
foreach (FieldDefinition definition in definitions)
|
||||
{
|
||||
AddOrUpdate(definition);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overridden to dynamically add field definitions for culture variations
|
||||
/// </summary>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Configuration;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
@@ -28,7 +30,10 @@ public class UnattendedUpgrader : INotificationAsyncHandler<RuntimeUnattendedUpg
|
||||
private readonly IRuntimeState _runtimeState;
|
||||
private readonly IUmbracoVersion _umbracoVersion;
|
||||
private readonly UnattendedSettings _unattendedSettings;
|
||||
private readonly DistributedCache _distributedCache;
|
||||
private readonly ILogger<UnattendedUpgrader> _logger;
|
||||
|
||||
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
public UnattendedUpgrader(
|
||||
IProfilingLogger profilingLogger,
|
||||
IUmbracoVersion umbracoVersion,
|
||||
@@ -36,13 +41,36 @@ public class UnattendedUpgrader : INotificationAsyncHandler<RuntimeUnattendedUpg
|
||||
IRuntimeState runtimeState,
|
||||
PackageMigrationRunner packageMigrationRunner,
|
||||
IOptions<UnattendedSettings> unattendedSettings)
|
||||
: this(
|
||||
profilingLogger,
|
||||
umbracoVersion,
|
||||
databaseBuilder,
|
||||
runtimeState,
|
||||
packageMigrationRunner,
|
||||
unattendedSettings,
|
||||
StaticServiceProvider.Instance.GetRequiredService<DistributedCache>(),
|
||||
StaticServiceProvider.Instance.GetRequiredService<ILogger<UnattendedUpgrader>>())
|
||||
{
|
||||
_profilingLogger = profilingLogger ?? throw new ArgumentNullException(nameof(profilingLogger));
|
||||
_umbracoVersion = umbracoVersion ?? throw new ArgumentNullException(nameof(umbracoVersion));
|
||||
_databaseBuilder = databaseBuilder ?? throw new ArgumentNullException(nameof(databaseBuilder));
|
||||
_runtimeState = runtimeState ?? throw new ArgumentNullException(nameof(runtimeState));
|
||||
}
|
||||
|
||||
public UnattendedUpgrader(
|
||||
IProfilingLogger profilingLogger,
|
||||
IUmbracoVersion umbracoVersion,
|
||||
DatabaseBuilder databaseBuilder,
|
||||
IRuntimeState runtimeState,
|
||||
PackageMigrationRunner packageMigrationRunner,
|
||||
IOptions<UnattendedSettings> unattendedSettings,
|
||||
DistributedCache distributedCache,
|
||||
ILogger<UnattendedUpgrader> logger)
|
||||
{
|
||||
_profilingLogger = profilingLogger;
|
||||
_umbracoVersion = umbracoVersion;
|
||||
_databaseBuilder = databaseBuilder;
|
||||
_runtimeState = runtimeState;
|
||||
_packageMigrationRunner = packageMigrationRunner;
|
||||
_unattendedSettings = unattendedSettings.Value;
|
||||
_distributedCache = distributedCache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task HandleAsync(RuntimeUnattendedUpgradeNotification notification, CancellationToken cancellationToken)
|
||||
@@ -109,8 +137,13 @@ public class UnattendedUpgrader : INotificationAsyncHandler<RuntimeUnattendedUpg
|
||||
try
|
||||
{
|
||||
await _packageMigrationRunner.RunPackagePlansAsync(pendingMigrations);
|
||||
notification.UnattendedUpgradeResult = RuntimeUnattendedUpgradeNotification.UpgradeResult
|
||||
.PackageMigrationComplete;
|
||||
notification.UnattendedUpgradeResult = RuntimeUnattendedUpgradeNotification.UpgradeResult.PackageMigrationComplete;
|
||||
|
||||
// Migration plans may have changed published content, so refresh the distributed cache to ensure consistency on first request.
|
||||
_distributedCache.RefreshAllPublishedSnapshot();
|
||||
_logger.LogInformation(
|
||||
"Migration plans run: {Plans}. Triggered refresh of distributed published content cache.",
|
||||
string.Join(", ", pendingMigrations));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Migrations;
|
||||
using Umbraco.Cms.Core.Models.Membership;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
@@ -51,9 +52,12 @@ public class MigrationPlanExecutor : IMigrationPlanExecutor
|
||||
private readonly DistributedCache _distributedCache;
|
||||
private readonly IScopeAccessor _scopeAccessor;
|
||||
private readonly ICoreScopeProvider _scopeProvider;
|
||||
private readonly IPublishedContentTypeFactory _publishedContentTypeFactory;
|
||||
|
||||
private bool _rebuildCache;
|
||||
private bool _invalidateBackofficeUserAccess;
|
||||
|
||||
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
public MigrationPlanExecutor(
|
||||
ICoreScopeProvider scopeProvider,
|
||||
IScopeAccessor scopeAccessor,
|
||||
@@ -65,6 +69,33 @@ public class MigrationPlanExecutor : IMigrationPlanExecutor
|
||||
IKeyValueService keyValueService,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
AppCaches appCaches)
|
||||
: this(
|
||||
scopeProvider,
|
||||
scopeAccessor,
|
||||
loggerFactory,
|
||||
migrationBuilder,
|
||||
databaseFactory,
|
||||
databaseCacheRebuilder,
|
||||
distributedCache,
|
||||
keyValueService,
|
||||
serviceScopeFactory,
|
||||
appCaches,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IPublishedContentTypeFactory>())
|
||||
{
|
||||
}
|
||||
|
||||
public MigrationPlanExecutor(
|
||||
ICoreScopeProvider scopeProvider,
|
||||
IScopeAccessor scopeAccessor,
|
||||
ILoggerFactory loggerFactory,
|
||||
IMigrationBuilder migrationBuilder,
|
||||
IUmbracoDatabaseFactory databaseFactory,
|
||||
IDatabaseCacheRebuilder databaseCacheRebuilder,
|
||||
DistributedCache distributedCache,
|
||||
IKeyValueService keyValueService,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
AppCaches appCaches,
|
||||
IPublishedContentTypeFactory publishedContentTypeFactory)
|
||||
{
|
||||
_scopeProvider = scopeProvider;
|
||||
_scopeAccessor = scopeAccessor;
|
||||
@@ -76,6 +107,7 @@ public class MigrationPlanExecutor : IMigrationPlanExecutor
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_appCaches = appCaches;
|
||||
_distributedCache = distributedCache;
|
||||
_publishedContentTypeFactory = publishedContentTypeFactory;
|
||||
_logger = _loggerFactory.CreateLogger<MigrationPlanExecutor>();
|
||||
}
|
||||
|
||||
@@ -301,6 +333,7 @@ public class MigrationPlanExecutor : IMigrationPlanExecutor
|
||||
_appCaches.IsolatedCaches.ClearAllCaches();
|
||||
await _databaseCacheRebuilder.RebuildAsync(false);
|
||||
_distributedCache.RefreshAllPublishedSnapshot();
|
||||
_publishedContentTypeFactory.ClearDataTypeCache();
|
||||
}
|
||||
|
||||
private async Task RevokeBackofficeTokens()
|
||||
|
||||
+7
-3
@@ -1381,9 +1381,13 @@ AND umbracoNode.id <> @id",
|
||||
}
|
||||
else if (ev.Key.langId.HasValue)
|
||||
{
|
||||
// This should never happen! If a property culture is flagged as edited then the culture must exist at the document level
|
||||
throw new PanicException(
|
||||
$"The existing DocumentCultureVariationDto was not found for node {ev.Key.nodeId} and language {ev.Key.langId}");
|
||||
// This can happen when a property changes from invariant to variant and the content
|
||||
// was only created in non-default languages. The invariant property data gets migrated
|
||||
// to the default language, but no DocumentCultureVariationDto exists for the default
|
||||
// language because the content was never created in that language.
|
||||
// In this case, we simply skip updating the edited flag since there's no document
|
||||
// culture variation record to update.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,8 @@ public abstract class BlockEditorPropertyValueEditor<TValue, TLayout> : BlockVal
|
||||
BlockEditorData<TValue, TLayout>? currentBlockEditorData = SafeParseBlockEditorData(currentValue);
|
||||
BlockEditorData<TValue, TLayout>? blockEditorData = SafeParseBlockEditorData(editorValue.Value);
|
||||
|
||||
CacheReferencedEntities(blockEditorData);
|
||||
|
||||
// We can skip MapBlockValueFromEditor if both editorValue and currentValue values are empty.
|
||||
if (IsBlockEditorDataEmpty(currentBlockEditorData) && IsBlockEditorDataEmpty(blockEditorData))
|
||||
{
|
||||
|
||||
@@ -43,6 +43,45 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : DataV
|
||||
_languageService = languageService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Caches referenced entities for all property values with supporting property editors within the specified block editor data
|
||||
/// optimising subsequent retrieval of entities when parsing and converting property values.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method iterates through all property values associated with data editors in the provided
|
||||
/// block editor data and invokes caching for referenced entities where supported by the property editor.
|
||||
/// </remarks>
|
||||
/// <param name="blockEditorData">The block editor data containing content and settings property values to analyze for referenced entities.</param>
|
||||
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
|
||||
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
|
||||
"Scheduled for removal in Umbraco 19.")]
|
||||
protected void CacheReferencedEntities(BlockEditorData<TValue, TLayout>? blockEditorData)
|
||||
{
|
||||
// Group property values by their associated data editor alias.
|
||||
IEnumerable<IGrouping<string, BlockPropertyValue>> valuesByDataEditors = (blockEditorData?.BlockValue.ContentData ?? []).Union(blockEditorData?.BlockValue.SettingsData ?? [])
|
||||
.SelectMany(x => x.Values)
|
||||
.Where(x => x.EditorAlias is not null && x.Value is not null)
|
||||
.GroupBy(x => x.EditorAlias!);
|
||||
|
||||
// Iterate through each group and cache referenced entities if supported by the data editor.
|
||||
foreach (IGrouping<string, BlockPropertyValue> valueByDataEditor in valuesByDataEditors)
|
||||
{
|
||||
IDataEditor? dataEditor = _propertyEditors[valueByDataEditor.Key];
|
||||
if (dataEditor is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
IDataValueEditor valueEditor = dataEditor.GetValueEditor();
|
||||
|
||||
if (valueEditor is ICacheReferencedEntities valueEditorWithPrecaching)
|
||||
{
|
||||
valueEditorWithPrecaching.CacheReferencedEntities(valueByDataEditor.Select(x => x.Value!));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract IEnumerable<UmbracoEntityReference> GetReferences(object? value);
|
||||
|
||||
|
||||
@@ -52,10 +52,8 @@ public class MediaPicker3PropertyEditor : DataEditor
|
||||
/// <summary>
|
||||
/// Defines the value editor for the media picker property editor.
|
||||
/// </summary>
|
||||
internal sealed class MediaPicker3PropertyValueEditor : DataValueEditor, IDataValueReference
|
||||
internal sealed class MediaPicker3PropertyValueEditor : DataValueEditor, IDataValueReference, ICacheReferencedEntities
|
||||
{
|
||||
private const string MediaCacheKeyFormat = nameof(MediaPicker3PropertyValueEditor) + "_Media_{0}";
|
||||
|
||||
private readonly IDataTypeConfigurationCache _dataTypeReadCache;
|
||||
private readonly IJsonSerializer _jsonSerializer;
|
||||
private readonly IMediaImportService _mediaImportService;
|
||||
@@ -107,6 +105,27 @@ public class MediaPicker3PropertyEditor : DataEditor
|
||||
Validators.Add(validators);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void CacheReferencedEntities(IEnumerable<object> values)
|
||||
{
|
||||
var mediaKeys = values
|
||||
.SelectMany(value => Deserialize(_jsonSerializer, value))
|
||||
.Select(dto => dto.MediaKey)
|
||||
.Distinct()
|
||||
.Where(x => IsMediaAlreadyCached(x, _appCaches.RequestCache) is false)
|
||||
.ToList();
|
||||
if (mediaKeys.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IEnumerable<IMedia> mediaItems = _mediaService.GetByIds(mediaKeys);
|
||||
foreach (IMedia media in mediaItems)
|
||||
{
|
||||
CacheMediaById(media, _appCaches.RequestCache);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<UmbracoEntityReference> GetReferences(object? value)
|
||||
{
|
||||
@@ -208,31 +227,13 @@ public class MediaPicker3PropertyEditor : DataEditor
|
||||
|
||||
foreach (MediaWithCropsDto mediaWithCropsDto in mediaWithCropsDtos)
|
||||
{
|
||||
IMedia? media = GetMediaById(mediaWithCropsDto.MediaKey);
|
||||
IMedia? media = GetAndCacheMediaById(mediaWithCropsDto.MediaKey, _appCaches.RequestCache, _mediaService);
|
||||
mediaWithCropsDto.MediaTypeAlias = media?.ContentType.Alias ?? unknownMediaType;
|
||||
}
|
||||
|
||||
return mediaWithCropsDtos.Where(m => m.MediaTypeAlias != unknownMediaType).ToList();
|
||||
}
|
||||
|
||||
private IMedia? GetMediaById(Guid key)
|
||||
{
|
||||
// Cache media lookups in case the same media is handled multiple times across a save operation,
|
||||
// which is possible, particularly if we have multiple languages and blocks.
|
||||
var cacheKey = string.Format(MediaCacheKeyFormat, key);
|
||||
IMedia? media = _appCaches.RequestCache.GetCacheItem<IMedia?>(cacheKey);
|
||||
if (media is null)
|
||||
{
|
||||
media = _mediaService.GetById(key);
|
||||
if (media is not null)
|
||||
{
|
||||
_appCaches.RequestCache.Set(cacheKey, media);
|
||||
}
|
||||
}
|
||||
|
||||
return media;
|
||||
}
|
||||
|
||||
private List<MediaWithCropsDto> HandleTemporaryMediaUploads(List<MediaWithCropsDto> mediaWithCropsDtos, MediaPicker3Configuration configuration)
|
||||
{
|
||||
var invalidDtos = new List<MediaWithCropsDto>();
|
||||
@@ -240,7 +241,7 @@ public class MediaPicker3PropertyEditor : DataEditor
|
||||
foreach (MediaWithCropsDto mediaWithCropsDto in mediaWithCropsDtos)
|
||||
{
|
||||
// if the media already exist, don't bother with it
|
||||
if (GetMediaById(mediaWithCropsDto.MediaKey) != null)
|
||||
if (GetAndCacheMediaById(mediaWithCropsDto.MediaKey, _appCaches.RequestCache, _mediaService) != null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -480,18 +481,7 @@ public class MediaPicker3PropertyEditor : DataEditor
|
||||
|
||||
foreach (var typeAlias in distinctTypeAliases)
|
||||
{
|
||||
// Cache media type lookups since the same media type is likely to be used multiple times in validation,
|
||||
// particularly if we have multiple languages and blocks.
|
||||
var cacheKey = string.Format(MediaTypeCacheKeyFormat, typeAlias);
|
||||
string? typeKey = _appCaches.RequestCache.GetCacheItem<string?>(cacheKey);
|
||||
if (typeKey is null)
|
||||
{
|
||||
typeKey = _mediaTypeService.Get(typeAlias)?.Key.ToString();
|
||||
if (typeKey is not null)
|
||||
{
|
||||
_appCaches.RequestCache.Set(cacheKey, typeKey);
|
||||
}
|
||||
}
|
||||
string? typeKey = GetMediaTypeKey(typeAlias);
|
||||
|
||||
if (typeKey is null || allowedTypes.Contains(typeKey) is false)
|
||||
{
|
||||
@@ -506,6 +496,31 @@ public class MediaPicker3PropertyEditor : DataEditor
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private string? GetMediaTypeKey(string typeAlias)
|
||||
{
|
||||
// Cache media type lookups since the same media type is likely to be used multiple times in validation,
|
||||
// particularly if we have multiple languages and blocks.
|
||||
string? GetMediaTypeKeyFromService(string typeAlias) => _mediaTypeService.Get(typeAlias)?.Key.ToString();
|
||||
|
||||
if (_appCaches.RequestCache.IsAvailable is false)
|
||||
{
|
||||
return GetMediaTypeKeyFromService(typeAlias);
|
||||
}
|
||||
|
||||
var cacheKey = string.Format(MediaTypeCacheKeyFormat, typeAlias);
|
||||
string? typeKey = _appCaches.RequestCache.GetCacheItem<string?>(cacheKey);
|
||||
if (typeKey is null)
|
||||
{
|
||||
typeKey = GetMediaTypeKeyFromService(typeAlias);
|
||||
if (typeKey is not null)
|
||||
{
|
||||
_appCaches.RequestCache.Set(cacheKey, typeKey);
|
||||
}
|
||||
}
|
||||
|
||||
return typeKey;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Runtime.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.IO;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.ContentEditing;
|
||||
@@ -19,14 +22,16 @@ using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
|
||||
public class MultiUrlPickerValueEditor : DataValueEditor, IDataValueReference
|
||||
public class MultiUrlPickerValueEditor : DataValueEditor, IDataValueReference, ICacheReferencedEntities
|
||||
{
|
||||
private readonly ILogger<MultiUrlPickerValueEditor> _logger;
|
||||
private readonly IPublishedUrlProvider _publishedUrlProvider;
|
||||
private readonly IJsonSerializer _jsonSerializer;
|
||||
private readonly IContentService _contentService;
|
||||
private readonly IMediaService _mediaService;
|
||||
private readonly AppCaches _appCaches;
|
||||
|
||||
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
public MultiUrlPickerValueEditor(
|
||||
ILogger<MultiUrlPickerValueEditor> logger,
|
||||
ILocalizedTextService localizedTextService,
|
||||
@@ -37,19 +42,102 @@ public class MultiUrlPickerValueEditor : DataValueEditor, IDataValueReference
|
||||
IIOHelper ioHelper,
|
||||
IContentService contentService,
|
||||
IMediaService mediaService)
|
||||
: this(
|
||||
logger,
|
||||
localizedTextService,
|
||||
shortStringHelper,
|
||||
attribute,
|
||||
publishedUrlProvider,
|
||||
jsonSerializer,
|
||||
ioHelper,
|
||||
contentService,
|
||||
mediaService,
|
||||
StaticServiceProvider.Instance.GetRequiredService<AppCaches>())
|
||||
{
|
||||
}
|
||||
|
||||
public MultiUrlPickerValueEditor(
|
||||
ILogger<MultiUrlPickerValueEditor> logger,
|
||||
ILocalizedTextService localizedTextService,
|
||||
IShortStringHelper shortStringHelper,
|
||||
DataEditorAttribute attribute,
|
||||
IPublishedUrlProvider publishedUrlProvider,
|
||||
IJsonSerializer jsonSerializer,
|
||||
IIOHelper ioHelper,
|
||||
IContentService contentService,
|
||||
IMediaService mediaService,
|
||||
AppCaches appCaches)
|
||||
: base(shortStringHelper, jsonSerializer, ioHelper, attribute)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_logger = logger;
|
||||
_publishedUrlProvider = publishedUrlProvider;
|
||||
|
||||
_jsonSerializer = jsonSerializer;
|
||||
_contentService = contentService;
|
||||
_mediaService = mediaService;
|
||||
_appCaches = appCaches;
|
||||
|
||||
Validators.Add(new TypedJsonValidatorRunner<LinkDisplay[], MultiUrlPickerConfiguration>(
|
||||
_jsonSerializer,
|
||||
new MinMaxValidator(localizedTextService)));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void CacheReferencedEntities(IEnumerable<object> values)
|
||||
{
|
||||
var dtos = values
|
||||
.Select(value =>
|
||||
{
|
||||
var asString = value is string str ? str : value.ToString();
|
||||
if (string.IsNullOrEmpty(asString))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return _jsonSerializer.Deserialize<List<LinkDto>>(asString);
|
||||
})
|
||||
.WhereNotNull()
|
||||
.SelectMany(x => x)
|
||||
.Where(x => x.Type == Constants.UdiEntityType.Document || x.Type == Constants.UdiEntityType.Media)
|
||||
.ToList();
|
||||
|
||||
IList<Guid> contentKeys = GetKeys(Constants.UdiEntityType.Document, dtos);
|
||||
IList<Guid> mediaKeys = GetKeys(Constants.UdiEntityType.Media, dtos);
|
||||
|
||||
if (contentKeys.Count > 0)
|
||||
{
|
||||
IEnumerable<IContent> contentItems = _contentService.GetByIds(contentKeys);
|
||||
foreach (IContent content in contentItems)
|
||||
{
|
||||
CacheContentById(content, _appCaches.RequestCache);
|
||||
}
|
||||
}
|
||||
|
||||
if (mediaKeys.Count > 0)
|
||||
{
|
||||
IEnumerable<IMedia> mediaItems = _mediaService.GetByIds(mediaKeys);
|
||||
foreach (IMedia media in mediaItems)
|
||||
{
|
||||
CacheMediaById(media, _appCaches.RequestCache);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private IList<Guid> GetKeys(string entityType, IEnumerable<LinkDto> dtos) =>
|
||||
dtos
|
||||
.Where(x => x.Type == entityType)
|
||||
.Select(x => x.Unique ?? (x.Udi is not null ? x.Udi.Guid : Guid.Empty))
|
||||
.Where(x => x != Guid.Empty)
|
||||
.Distinct()
|
||||
.Where(x => IsAlreadyCached(x, entityType) is false)
|
||||
.ToList();
|
||||
|
||||
private bool IsAlreadyCached(Guid key, string entityType) => entityType switch
|
||||
{
|
||||
Constants.UdiEntityType.Document => IsContentAlreadyCached(key, _appCaches.RequestCache),
|
||||
Constants.UdiEntityType.Media => IsMediaAlreadyCached(key, _appCaches.RequestCache),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
public IEnumerable<UmbracoEntityReference> GetReferences(object? value)
|
||||
{
|
||||
var asString = value == null ? string.Empty : value is string str ? str : value.ToString();
|
||||
@@ -105,7 +193,7 @@ public class MultiUrlPickerValueEditor : DataValueEditor, IDataValueReference
|
||||
if (dto.Udi.EntityType == Constants.UdiEntityType.Document)
|
||||
{
|
||||
url = _publishedUrlProvider.GetUrl(dto.Udi.Guid, UrlMode.Relative, culture);
|
||||
IContent? c = _contentService.GetById(dto.Udi.Guid);
|
||||
IContent? c = GetAndCacheContentById(dto.Udi.Guid, _appCaches.RequestCache, _contentService);
|
||||
|
||||
if (c is not null)
|
||||
{
|
||||
@@ -119,7 +207,7 @@ public class MultiUrlPickerValueEditor : DataValueEditor, IDataValueReference
|
||||
else if (dto.Udi.EntityType == Constants.UdiEntityType.Media)
|
||||
{
|
||||
url = _publishedUrlProvider.GetMediaUrl(dto.Udi.Guid, UrlMode.Relative, culture);
|
||||
IMedia? m = _mediaService.GetById(dto.Udi.Guid);
|
||||
IMedia? m = GetAndCacheMediaById(dto.Udi.Guid, _appCaches.RequestCache, _mediaService);
|
||||
if (m is not null)
|
||||
{
|
||||
published = m.Trashed is false;
|
||||
@@ -207,6 +295,12 @@ public class MultiUrlPickerValueEditor : DataValueEditor, IDataValueReference
|
||||
[DataMember(Name = "target")]
|
||||
public string? Target { get; set; }
|
||||
|
||||
[DataMember(Name = "unique")]
|
||||
public Guid? Unique { get; set; }
|
||||
|
||||
[DataMember(Name = "type")]
|
||||
public string? Type { get; set; }
|
||||
|
||||
[DataMember(Name = "udi")]
|
||||
public GuidUdi? Udi { get; set; }
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ internal sealed class DatabaseCacheRebuilder : IDatabaseCacheRebuilder
|
||||
|
||||
_logger.LogWarning(
|
||||
"Database cache was serialized using {CurrentSerializer}. Currently configured cache serializer {Serializer}. Rebuilding database cache.",
|
||||
currentSerializer,
|
||||
currentSerializer == 0 ? "None" : currentSerializer,
|
||||
serializer);
|
||||
|
||||
using (_profilingLogger.TraceDuration<DatabaseCacheRebuilder>($"Rebuilding database cache with {serializer} serializer"))
|
||||
|
||||
@@ -38,7 +38,7 @@ internal sealed class PublishedContentFactory : IPublishedContentFactory
|
||||
/// <inheritdoc/>
|
||||
public IPublishedContent? ToIPublishedContent(ContentCacheNode contentCacheNode, bool preview)
|
||||
{
|
||||
var cacheKey = $"{nameof(PublishedContentFactory)}DocumentCache_{contentCacheNode.Id}_{preview}";
|
||||
var cacheKey = $"{nameof(PublishedContentFactory)}DocumentCache_{contentCacheNode.Id}_{preview}_{contentCacheNode.Data?.VersionDate.Ticks ?? 0}";
|
||||
IPublishedContent? publishedContent = null;
|
||||
if (_appCaches.RequestCache.IsAvailable)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Persistence.Repositories;
|
||||
using Umbraco.Cms.Core.Web;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Web.Common.Repositories;
|
||||
@@ -11,21 +14,35 @@ internal sealed class WebProfilerRepository : IWebProfilerRepository
|
||||
private const string QueryName = "umbDebug";
|
||||
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly ICookieManager _cookieManager;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
|
||||
public WebProfilerRepository(IHttpContextAccessor httpContextAccessor)
|
||||
public WebProfilerRepository(IHttpContextAccessor httpContextAccessor, ICookieManager cookieManager, IOptions<GlobalSettings> globalSettings)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_cookieManager = cookieManager;
|
||||
_globalSettings = globalSettings.Value;
|
||||
}
|
||||
|
||||
public void SetStatus(int userId, bool status)
|
||||
{
|
||||
if (status)
|
||||
{
|
||||
_httpContextAccessor.GetRequiredHttpContext().Response.Cookies.Append(CookieName, "1", new CookieOptions { Expires = DateTime.Now.AddYears(1) });
|
||||
// This cookie enables debug profiling on the front-end without needing query strings or headers.
|
||||
// It uses SameSite=Strict, so it only works when the BackOffice and front-end share the same domain.
|
||||
// It's marked httpOnly to prevent JavaScript access (the server reads it, not client-side code).
|
||||
// No expiration is set, so it's a session cookie and will be deleted when the browser closes.
|
||||
// For cross-site setups, use the query string (?umbDebug=true) or header (X-UMB-DEBUG) instead.
|
||||
_cookieManager.SetCookieValue(
|
||||
CookieName,
|
||||
"1",
|
||||
httpOnly: true,
|
||||
secure: _globalSettings.UseHttps,
|
||||
sameSiteMode: "Strict");
|
||||
}
|
||||
else
|
||||
{
|
||||
_httpContextAccessor.GetRequiredHttpContext().Response.Cookies.Delete(CookieName);
|
||||
_cookieManager.ExpireCookie(CookieName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +60,6 @@ internal sealed class WebProfilerRepository : IWebProfilerRepository
|
||||
return xUmbDebug;
|
||||
}
|
||||
|
||||
return request.Cookies.ContainsKey(CookieName);
|
||||
return _cookieManager.HasCookie(CookieName);
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -26,6 +26,7 @@ If you have an existing Vite server running, you can run the task **Backoffice A
|
||||
### Run a Front-end server against a local Umbraco instance
|
||||
|
||||
#### 1. Configure Umbraco instance
|
||||
|
||||
Enable the front-end server communicating with the Backend server(Umbraco instance) you need need to correct the `appsettings.json` of your project.
|
||||
|
||||
For code contributions use the backend project of `/src/Umbraco.Web.UI`.
|
||||
@@ -38,7 +39,11 @@ Open this file in an editor: `/src/Umbraco.Web.UI/appsettings.Development.json`
|
||||
"BackOfficeHost": "http://localhost:5173",
|
||||
"AuthorizeCallbackPathName": "/oauth_complete",
|
||||
"AuthorizeCallbackLogoutPathName": "/logout",
|
||||
"AuthorizeCallbackErrorPathName": "/error",
|
||||
"AuthorizeCallbackErrorPathName": "/error",,
|
||||
"BackOfficeTokenCookie": {
|
||||
"Enabled": true,
|
||||
"SameSite": "None"
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -46,10 +51,15 @@ Open this file in an editor: `/src/Umbraco.Web.UI/appsettings.Development.json`
|
||||
|
||||
This will override the backoffice host URL, enabling the Client to run from a different origin.
|
||||
|
||||
> [!NOTE]
|
||||
> If you get stuck in a login loop, try clearing your browser cookies for localhost, and make sure that the `BackOfficeTokenCookie` settings are correct. Namely, that `SameSite` should be set to `None` when running the front-end server separately.
|
||||
|
||||
#### 2. Start Umbraco
|
||||
|
||||
Then start the backend server by running the command: `dotnet run` in the `/src/Umbraco.Web.UI` folder.
|
||||
|
||||
#### 3. Start Frontend server
|
||||
|
||||
Now start the frontend server by running the command: `npm run dev:server` in the `/src/Umbraco.Web.UI.Client` folder.
|
||||
|
||||
Finally open `http://localhost:5173` in your browser.
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
This package contains the types for the Umbraco Backoffice.
|
||||
|
||||
## Preview
|
||||
|
||||
A live preview of the latest backoffice build from the main branch is available at:
|
||||
|
||||
**[backofficepreview.umbraco.com](https://backofficepreview.umbraco.com/)**
|
||||
|
||||
This preview is automatically deployed via GitHub Actions whenever changes are pushed to main or version branches.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
|
||||
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@umbraco-cms/backoffice",
|
||||
"version": "16.4.0-rc",
|
||||
"version": "16.5.0-rc",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@umbraco-cms/backoffice",
|
||||
"version": "16.4.0-rc",
|
||||
"version": "16.5.0-rc",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"./src/packages/*",
|
||||
@@ -64,8 +64,8 @@
|
||||
"web-component-analyzer": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22",
|
||||
"npm": ">=10.9"
|
||||
"node": ">=22.17.1",
|
||||
"npm": ">=10.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
@@ -17062,7 +17062,7 @@
|
||||
"src/packages/core": {
|
||||
"name": "@umbraco-backoffice/core",
|
||||
"dependencies": {
|
||||
"@hey-api/openapi-ts": "0.85.0",
|
||||
"@hey-api/openapi-ts": "^0.85.0",
|
||||
"@types/diff": "^7.0.2",
|
||||
"diff": "^7.0.0",
|
||||
"uuid": "^13.0.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@umbraco-cms/backoffice",
|
||||
"license": "MIT",
|
||||
"version": "16.4.0-rc",
|
||||
"version": "16.6.0-rc",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": null,
|
||||
@@ -212,8 +212,8 @@
|
||||
"generate:ui-api-docs": "npm run generate:check-const-test && typedoc --options typedoc.config.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22",
|
||||
"npm": ">=10.9"
|
||||
"node": ">=22.17.1",
|
||||
"npm": ">=10.9.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"element-internals-polyfill": "^3.0.2"
|
||||
|
||||
+3
-2
@@ -83,8 +83,9 @@ export class UmbBackofficeHeaderSectionsElement extends UmbLitElement {
|
||||
|
||||
const clickedSectionAlias = manifest.alias;
|
||||
|
||||
// If the clicked section is the same as the current section, we just load the original section path to load the section root
|
||||
if (this._currentSectionAlias === clickedSectionAlias) {
|
||||
// If preventUrlRetention is set to true then go to the section root.
|
||||
// Or if the clicked section is the current active one, then navigate to the section root
|
||||
if (manifest?.meta.preventUrlRetention === true || this._currentSectionAlias === clickedSectionAlias) {
|
||||
const sectionPath = this.#getSectionPath(manifest);
|
||||
history.pushState(null, '', sectionPath);
|
||||
return;
|
||||
|
||||
@@ -63,7 +63,7 @@ export class UmbPreviewElement extends UmbLitElement {
|
||||
src=${this._previewUrl}
|
||||
title="Page preview"
|
||||
@load=${this.#onIFrameLoad}
|
||||
sandbox="allow-scripts allow-same-origin"></iframe>
|
||||
sandbox="allow-scripts allow-same-origin allow-forms"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
<div id="menu">
|
||||
|
||||
@@ -2525,13 +2525,19 @@ export default {
|
||||
profiling: {
|
||||
performanceProfiling: 'Performance profiling',
|
||||
performanceProfilingDescription:
|
||||
"<p>Umbraco currently runs in debug mode. This means you can use the built-in performance profiler to assess the performance when rendering pages.</p><p>If you want to activate the profiler for a specific page rendering, simply add <strong>umbDebug=true</strong> to the querystring when requesting the page.</p><p>If you want the profiler to be activated by default for all page renderings, you can use the toggle below. It will set a cookie in your browser, which then activates the profiler automatically. In other words, the profiler will only be active by default in <em>your</em> browser - not everyone else's.</p>",
|
||||
"<p>Umbraco currently runs in debug mode. This means you can use the built-in performance profiler to assess the performance when rendering pages.</p><p>If you want to activate the profiler for a specific page rendering, simply add <strong>umbDebug=true</strong> to the querystring when requesting the page.</p><p>If you want the profiler to be activated by default for all page renderings, you can use the toggle below. It will set a cookie in your browser, which then activates the profiler automatically. In other words, the profiler will only be active by default in <em>your</em> browser - not everyone else's.</p><p><strong>Note:</strong> This will only work if the Backoffice is currently located on the same URL as the front-end website.</p>",
|
||||
activateByDefault: 'Activate the profiler by default',
|
||||
reminder: 'Friendly reminder',
|
||||
reminderDescription:
|
||||
'<p>You should never let a production site run in debug mode. Debug mode is turned off by setting <strong>Umbraco:CMS:Hosting:Debug</strong> to <strong>false</strong> in appsettings.json, appsettings.{Environment}.json or via an environment variable.</p>',
|
||||
profilerEnabledDescription:
|
||||
"<p>Umbraco currently does not run in debug mode, so you can't use the built-in profiler. This is how it should be for a production site.</p><p>Debug mode is turned on by setting <strong>Umbraco:CMS:Hosting:Debug</strong> to <strong>true</strong> in appsettings.json, appsettings.{Environment}.json or via an environment variable.</p>",
|
||||
errorEnablingProfilerTitle: 'Error enabling profiler',
|
||||
errorEnablingProfilerDescription:
|
||||
'It was not possible to enable the profiler. Check that you are accessing the Backoffice on the same URL as the front-end website, and try again. If the problem persists, please check the log for more details.',
|
||||
errorDisablingProfilerTitle: 'Error disabling profiler',
|
||||
errorDisablingProfilerDescription:
|
||||
'It was not possible to disable the profiler. Try again, and if the problem persists, please check the log for more details.',
|
||||
},
|
||||
settingsDashboardVideos: {
|
||||
trainingHeadline: 'Hours of Umbraco training videos are only a click away',
|
||||
@@ -2869,29 +2875,52 @@ export default {
|
||||
ar: 'العربية',
|
||||
bs: 'Bosanski',
|
||||
cs: 'Česky',
|
||||
'cs-cz': 'Česky (Czechia)',
|
||||
cy: 'Cymraeg',
|
||||
'cy-gb': 'Cymraeg (UK)',
|
||||
da: 'Dansk',
|
||||
'da-dk': 'Dansk (Danmark)',
|
||||
de: 'Deutsch',
|
||||
'de-de': 'Deutsch (Deutschland)',
|
||||
'de-ch': 'Deutsch (Schweiz)',
|
||||
en: 'English (UK)',
|
||||
'en-us': 'English (US)',
|
||||
es: 'Español',
|
||||
'es-es': 'Español (España)',
|
||||
fr: 'Français',
|
||||
he: 'Hebrew',
|
||||
'fr-fr': 'Français (France)',
|
||||
'fr-ch': 'Français (Suisse)',
|
||||
he: 'עברית',
|
||||
'he-il': 'עברית (ישראל)',
|
||||
hr: 'Hrvatski',
|
||||
'hr-hr': 'Hrvatski (Hrvatska)',
|
||||
it: 'Italiano',
|
||||
'it-it': 'Italiano (Italia)',
|
||||
'it-ch': 'Italiano (Svizzera)',
|
||||
ja: '日本語',
|
||||
'ja-jp': '日本語 (日本)',
|
||||
ko: '한국어',
|
||||
'ko-kr': '한국어 (한국)',
|
||||
nb: 'Norsk Bokmål',
|
||||
'nb-no': 'Norsk (Bokmål)',
|
||||
nl: 'Nederlands',
|
||||
'nl-nl': 'Nederlands (Nederland)',
|
||||
pl: 'Polski',
|
||||
'pl-pl': 'Polski (Polska)',
|
||||
pt: 'Português',
|
||||
'pt-br': 'Português (Brasil)',
|
||||
ro: 'Romana',
|
||||
ro: 'Română',
|
||||
'ro-ro': 'Română (România)',
|
||||
ru: 'Русский',
|
||||
'ru-ru': 'Русский (Россия)',
|
||||
sv: 'Svenska',
|
||||
'sv-se': 'Svenska (Sverige)',
|
||||
tr: 'Türkçe',
|
||||
'tr-tr': 'Türkçe (Türkiye Cumhuriyeti)',
|
||||
uk: 'Українська',
|
||||
'uk-ua': 'Українська (Україна)',
|
||||
zh: '中文',
|
||||
'zh-cn': '中文(简体,中国)',
|
||||
'zh-tw': '中文(正體,台灣)',
|
||||
vi: 'Tiếng Việt',
|
||||
},
|
||||
|
||||
@@ -2836,34 +2836,4 @@ export default {
|
||||
resetUrlMessage: 'Bạn có chắc chắn muốn đặt lại URL này không?',
|
||||
resetUrlLabel: 'Đặt lại',
|
||||
},
|
||||
uiCulture: {
|
||||
ar: 'العربية',
|
||||
bs: 'Bosanski',
|
||||
cs: 'Česky',
|
||||
cy: 'Cymraeg',
|
||||
da: 'Dansk',
|
||||
de: 'Deutsch',
|
||||
en: 'English (UK)',
|
||||
'en-us': 'English (US)',
|
||||
es: 'Español',
|
||||
fr: 'Français',
|
||||
he: 'Hebrew',
|
||||
hr: 'Hrvatski',
|
||||
it: 'Italiano',
|
||||
ja: '日本語',
|
||||
ko: '한국어',
|
||||
nb: 'Norsk Bokmål',
|
||||
nl: 'Nederlands',
|
||||
pl: 'Polski',
|
||||
pt: 'Português',
|
||||
'pt-br': 'Português (Brasil)',
|
||||
ro: 'Romana',
|
||||
ru: 'Русский',
|
||||
sv: 'Svenska',
|
||||
tr: 'Türkçe',
|
||||
uk: 'Українська',
|
||||
zh: '中文',
|
||||
'zh-tw': '中文(正體,台灣)',
|
||||
vi: 'Tiếng Việt',
|
||||
},
|
||||
} as UmbLocalizationDictionary;
|
||||
|
||||
+41
-7
@@ -77,6 +77,41 @@ export class RedirectRequestHandler extends AuthorizationRequestHandler {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all stale authorization requests and configurations from storage.
|
||||
* This scans localStorage for any keys matching the appauth patterns and removes them,
|
||||
* including the authorization request handle key.
|
||||
*/
|
||||
public cleanupStaleAuthorizationData(): Promise<void> {
|
||||
// Check if we're in a browser environment with localStorage
|
||||
if (typeof window === 'undefined' || !window.localStorage) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const keysToRemove: string[] = [];
|
||||
|
||||
// Scan localStorage for all appauth-related keys
|
||||
for (let i = 0; i < window.localStorage.length; i++) {
|
||||
const key = window.localStorage.key(i);
|
||||
if (
|
||||
key &&
|
||||
(key.includes('_appauth_authorization_request') ||
|
||||
key.includes('_appauth_authorization_service_configuration') ||
|
||||
key === AUTHORIZATION_REQUEST_HANDLE_KEY)
|
||||
) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove all found stale keys
|
||||
const removePromises = keysToRemove.map((key) => this.storageBackend.removeItem(key));
|
||||
return Promise.all(removePromises).then(() => {
|
||||
if (keysToRemove.length > 0) {
|
||||
log(`Cleaned up ${keysToRemove.length} stale authorization data entries`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to introspect the contents of storage backend and completes the
|
||||
* request.
|
||||
@@ -119,12 +154,8 @@ export class RedirectRequestHandler extends AuthorizationRequestHandler {
|
||||
} else {
|
||||
authorizationResponse = new AuthorizationResponse({ code: code, state: state });
|
||||
}
|
||||
// cleanup state
|
||||
return Promise.all([
|
||||
this.storageBackend.removeItem(AUTHORIZATION_REQUEST_HANDLE_KEY),
|
||||
this.storageBackend.removeItem(authorizationRequestKey(handle)),
|
||||
this.storageBackend.removeItem(authorizationServiceConfigurationKey(handle)),
|
||||
]).then(() => {
|
||||
// cleanup all authorization data including current and stale entries
|
||||
return this.cleanupStaleAuthorizationData().then(() => {
|
||||
log('Delivering authorization response');
|
||||
return {
|
||||
request: request,
|
||||
@@ -134,7 +165,10 @@ export class RedirectRequestHandler extends AuthorizationRequestHandler {
|
||||
});
|
||||
} else {
|
||||
log('Mismatched request (state and request_uri) dont match.');
|
||||
return Promise.resolve(null);
|
||||
// cleanup all authorization data even on mismatch to prevent stale PKCE data
|
||||
return this.cleanupStaleAuthorizationData().then(() => {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -35,6 +35,7 @@ export class FetchRequestor extends Requestor {
|
||||
const requestInit: RequestInit = {};
|
||||
requestInit.method = settings.method;
|
||||
requestInit.mode = 'cors';
|
||||
requestInit.credentials = settings.credentials ?? 'include';
|
||||
|
||||
if (settings.data) {
|
||||
if (settings.method && settings.method.toUpperCase() === 'POST') {
|
||||
|
||||
+5
@@ -198,6 +198,9 @@ export class UmbBlockGridEntriesElement extends UmbFormControlMixin(UmbLitElemen
|
||||
@state()
|
||||
private _isReadOnly: boolean = false;
|
||||
|
||||
@state()
|
||||
private _limitMax?: number;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
@@ -294,6 +297,7 @@ export class UmbBlockGridEntriesElement extends UmbFormControlMixin(UmbLitElemen
|
||||
}
|
||||
|
||||
async #setupRangeValidation(rangeLimit: UmbNumberRangeValueType | undefined) {
|
||||
this._limitMax = rangeLimit?.max;
|
||||
if (this.#rangeUnderflowValidator) {
|
||||
this.removeValidator(this.#rangeUnderflowValidator);
|
||||
this.#rangeUnderflowValidator = undefined;
|
||||
@@ -408,6 +412,7 @@ export class UmbBlockGridEntriesElement extends UmbFormControlMixin(UmbLitElemen
|
||||
}
|
||||
|
||||
#renderCreateButtonGroup() {
|
||||
if (this._limitMax === 1 && this._layoutEntries.length > 0) return nothing;
|
||||
if (this._areaKey === null || this._layoutEntries.length === 0) {
|
||||
return html` <uui-button-group id="createButton">
|
||||
${this.#renderCreateButton()} ${this.#renderPasteButton()}
|
||||
|
||||
+9
-2
@@ -17,7 +17,7 @@ import type {
|
||||
UmbPropertyEditorUiElement,
|
||||
UmbPropertyEditorConfigCollection,
|
||||
} from '@umbraco-cms/backoffice/property-editor';
|
||||
import { observeMultiple } from '@umbraco-cms/backoffice/observable-api';
|
||||
import { jsonStringComparison, observeMultiple } from '@umbraco-cms/backoffice/observable-api';
|
||||
import { UMB_PROPERTY_CONTEXT, UMB_PROPERTY_DATASET_CONTEXT } from '@umbraco-cms/backoffice/property';
|
||||
import { UmbFormControlMixin, UmbValidationContext } from '@umbraco-cms/backoffice/validation';
|
||||
import type { UmbBlockTypeGroup } from '@umbraco-cms/backoffice/block-type';
|
||||
@@ -181,15 +181,22 @@ export class UmbPropertyEditorUIBlockGridElement
|
||||
]).pipe(debounceTime(20)),
|
||||
([layouts, contents, settings, exposes]) => {
|
||||
if (layouts.length === 0) {
|
||||
if (this.value === undefined) {
|
||||
return;
|
||||
}
|
||||
super.value = undefined;
|
||||
} else {
|
||||
super.value = {
|
||||
const newValue = {
|
||||
...super.value,
|
||||
layout: { [UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS]: layouts },
|
||||
contentData: contents,
|
||||
settingsData: settings,
|
||||
expose: exposes,
|
||||
};
|
||||
if (jsonStringComparison(this.value, newValue)) {
|
||||
return;
|
||||
}
|
||||
super.value = newValue;
|
||||
}
|
||||
|
||||
// If we don't have a value set from the outside or an internal value, we don't want to set the value.
|
||||
|
||||
-4
@@ -292,10 +292,6 @@ export class UmbInlineListBlockElement extends UmbLitElement {
|
||||
padding-left: var(--uui-size-2, 6px);
|
||||
}
|
||||
|
||||
#name {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
uui-tag {
|
||||
margin-left: 0.5em;
|
||||
margin-bottom: -0.3em;
|
||||
|
||||
+11
-7
@@ -25,7 +25,7 @@ import {
|
||||
UmbFormControlMixin,
|
||||
UmbValidationContext,
|
||||
} from '@umbraco-cms/backoffice/validation';
|
||||
import { observeMultiple } from '@umbraco-cms/backoffice/observable-api';
|
||||
import { jsonStringComparison, observeMultiple } from '@umbraco-cms/backoffice/observable-api';
|
||||
import { debounceTime } from '@umbraco-cms/backoffice/external/rxjs';
|
||||
import { UMB_CONTENT_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/content';
|
||||
|
||||
@@ -339,15 +339,22 @@ export class UmbPropertyEditorUIBlockListElement
|
||||
]).pipe(debounceTime(20)),
|
||||
([layouts, contents, settings, exposes]) => {
|
||||
if (layouts.length === 0) {
|
||||
if (this.value === undefined) {
|
||||
return;
|
||||
}
|
||||
super.value = undefined;
|
||||
} else {
|
||||
super.value = {
|
||||
const newValue = {
|
||||
...super.value,
|
||||
layout: { [UMB_BLOCK_LIST_PROPERTY_EDITOR_SCHEMA_ALIAS]: layouts },
|
||||
contentData: contents,
|
||||
settingsData: settings,
|
||||
expose: exposes,
|
||||
};
|
||||
if (jsonStringComparison(this.value, newValue)) {
|
||||
return;
|
||||
}
|
||||
super.value = newValue;
|
||||
}
|
||||
|
||||
// If we don't have a value set from the outside or an internal value, we don't want to set the value.
|
||||
@@ -389,11 +396,8 @@ export class UmbPropertyEditorUIBlockListElement
|
||||
}
|
||||
|
||||
#renderCreateButtonGroup() {
|
||||
if (this.readonly && this._layouts.length > 0) {
|
||||
return nothing;
|
||||
} else {
|
||||
return html`<uui-button-group>${this.#renderCreateButton()}${this.#renderPasteButton()}</uui-button-group>`;
|
||||
}
|
||||
if (this._layouts.length > 0 && (this._limitMax === 1 || this.readonly)) return nothing;
|
||||
return html`<uui-button-group>${this.#renderCreateButton()}${this.#renderPasteButton()}</uui-button-group>`;
|
||||
}
|
||||
|
||||
#renderInlineCreateButton(index: number) {
|
||||
|
||||
+18
-22
@@ -14,7 +14,7 @@ import type { UmbWorkspaceViewElement } from '@umbraco-cms/backoffice/workspace'
|
||||
*/
|
||||
@customElement('umb-block-workspace-view-edit-content-no-router')
|
||||
export class UmbBlockWorkspaceViewEditContentNoRouterElement extends UmbLitElement implements UmbWorkspaceViewElement {
|
||||
//private _hasRootProperties = false;
|
||||
// private _hasRootProperties = false;
|
||||
|
||||
@state()
|
||||
private _hasRootGroups = false;
|
||||
@@ -25,9 +25,6 @@ export class UmbBlockWorkspaceViewEditContentNoRouterElement extends UmbLitEleme
|
||||
@state()
|
||||
private _activeTabKey?: string | null | undefined;
|
||||
|
||||
//@state()
|
||||
//private _activeTabName?: string | null | undefined;
|
||||
|
||||
#blockWorkspace?: typeof UMB_BLOCK_WORKSPACE_CONTEXT.TYPE;
|
||||
#tabsStructureHelper = new UmbContentTypeContainerStructureHelper(this);
|
||||
|
||||
@@ -67,46 +64,45 @@ export class UmbBlockWorkspaceViewEditContentNoRouterElement extends UmbLitEleme
|
||||
#checkDefaultTabName() {
|
||||
if (!this._tabs || !this.#blockWorkspace) return;
|
||||
|
||||
// Find the default tab to grab:
|
||||
// Find the default tab to grab
|
||||
if (this._activeTabKey === undefined) {
|
||||
if (this._hasRootGroups) {
|
||||
//this._activeTabName = null;
|
||||
this._activeTabKey = null;
|
||||
} else if (this._tabs.length > 0) {
|
||||
//this._activeTabName = this._tabs[0].name;
|
||||
this._activeTabKey = this._tabs[0].key;
|
||||
const tab = this._tabs[0];
|
||||
this._activeTabKey = tab.ownerId ?? tab.ids[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#setTabName(tabName: string | undefined | null, tabKey: string | null | undefined) {
|
||||
//this._activeTabName = tabName;
|
||||
#setTabKey(tabKey: string | null | undefined) {
|
||||
this._activeTabKey = tabKey;
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this._tabs) return;
|
||||
|
||||
return html`
|
||||
${this._tabs.length > 1 || (this._tabs.length === 1 && this._hasRootGroups)
|
||||
? html` <uui-tab-group slot="header">
|
||||
? html`<uui-tab-group slot="header">
|
||||
${this._hasRootGroups && this._tabs.length > 0
|
||||
? html`
|
||||
<uui-tab
|
||||
label="Content"
|
||||
.active=${null === this._activeTabKey}
|
||||
@click=${() => this.#setTabName(null, null)}
|
||||
>Content</uui-tab
|
||||
>
|
||||
`
|
||||
? html`<uui-tab
|
||||
label=${this.localize.term('general_generic')}
|
||||
.active=${this._activeTabKey === null}
|
||||
@click=${() => this.#setTabKey(null)}
|
||||
>Content</uui-tab
|
||||
>`
|
||||
: nothing}
|
||||
${repeat(
|
||||
this._tabs,
|
||||
(tab) => tab.name,
|
||||
(tab) => {
|
||||
const tabKey = tab.ownerId ?? tab.ids[0];
|
||||
|
||||
return html`<uui-tab
|
||||
label=${tab.name ?? 'Unnamed'}
|
||||
.active=${tab.key === this._activeTabKey}
|
||||
@click=${() => this.#setTabName(tab.name, tab.key)}
|
||||
label=${this.localize.string(tab.name ?? '#general_unnamed')}
|
||||
.active=${this._activeTabKey === tabKey}
|
||||
@click=${() => this.#setTabKey(tabKey)}
|
||||
>${tab.name}</uui-tab
|
||||
>`;
|
||||
},
|
||||
|
||||
+5
-2
@@ -420,6 +420,8 @@ export abstract class UmbContentDetailWorkspaceContextBase<
|
||||
const repo = new UmbDataTypeDetailRepository(this);
|
||||
|
||||
const propertyTypes = await this.structure.getContentTypeProperties();
|
||||
const contentTypeVariesByCulture = this.structure.getVariesByCulture();
|
||||
const contentTypeVariesBySegment = this.structure.getVariesBySegment();
|
||||
const valueDefinitions = await Promise.all(
|
||||
propertyTypes.map(async (property) => {
|
||||
// TODO: Implement caching for data-type requests. [NL]
|
||||
@@ -438,8 +440,9 @@ export abstract class UmbContentDetailWorkspaceContextBase<
|
||||
propertyEditorSchemaAlias: dataType.editorAlias,
|
||||
config: dataType.values,
|
||||
typeArgs: {
|
||||
variesByCulture: property.variesByCulture,
|
||||
variesBySegment: property.variesBySegment,
|
||||
// Only vary if the content type varies:
|
||||
variesByCulture: contentTypeVariesByCulture ? property.variesByCulture : false,
|
||||
variesBySegment: contentTypeVariesBySegment ? property.variesBySegment : false,
|
||||
} as UmbPropertyTypePresetModelTypeModel,
|
||||
} as UmbPropertyTypePresetModel;
|
||||
}),
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@ export class UmbPropertyTypeWorkspaceViewSettingsElement extends UmbLitElement i
|
||||
|
||||
#onAliasChange() {
|
||||
// TODO: Why can I not get the correct value via event? Is it an issue in uui library too?
|
||||
const alias = generateAlias(this._aliasInput.value.toString());
|
||||
const alias = this._aliasInput.value.toString();
|
||||
this.updateValue({ alias });
|
||||
}
|
||||
|
||||
|
||||
@@ -247,6 +247,10 @@ export class UmbAuthFlow {
|
||||
|
||||
// clear the internal state
|
||||
this.#tokenResponse.setValue(undefined);
|
||||
|
||||
// Also cleanup any OAuth/PKCE artifacts that may still be in localStorage
|
||||
// This is a defense-in-depth measure during logout
|
||||
await this.#authorizationHandler.cleanupStaleAuthorizationData();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -361,6 +365,7 @@ export class UmbAuthFlow {
|
||||
const token = await this.performWithFreshTokens();
|
||||
const request = new Request(this.#unlink_endpoint, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ loginProvider, providerKey }),
|
||||
});
|
||||
@@ -454,6 +459,7 @@ export class UmbAuthFlow {
|
||||
const token = await this.performWithFreshTokens();
|
||||
|
||||
const request = await fetch(`${this.#link_key_endpoint}?provider=${provider}`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
+1
@@ -221,6 +221,7 @@ export class UmbInputMultipleTextStringElement extends UmbFormControlMixin<undef
|
||||
|
||||
#renderAddButton() {
|
||||
if (this.disabled || this.readonly) return nothing;
|
||||
if (this.max === 1 && this._items.length > 0) return nothing;
|
||||
return html`
|
||||
<uui-button
|
||||
color="default"
|
||||
|
||||
@@ -1 +1,14 @@
|
||||
export { client as umbHttpClient } from '@umbraco-cms/backoffice/external/backend-api';
|
||||
import { client } from '@umbraco-cms/backoffice/external/backend-api';
|
||||
|
||||
/**
|
||||
* Pre-configure the client with default credentials for cookie-based authentication.
|
||||
* This ensures all requests include cookies by default, which is required for
|
||||
* cookie-based authentication in Umbraco 17.0+.
|
||||
*
|
||||
* Extensions using this client will automatically get credentials: 'include'.
|
||||
*/
|
||||
client.setConfig({
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
export { client as umbHttpClient };
|
||||
|
||||
+52
-32
@@ -30,6 +30,9 @@ export class UmbIconPickerModalElement extends UmbModalBaseElement<UmbIconPicker
|
||||
@state()
|
||||
private _colorList = umbracoColors.filter((color) => !color.legacy);
|
||||
|
||||
@state()
|
||||
private _isSearching = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.consumeContext(UMB_ICON_REGISTRY_CONTEXT, (context) => {
|
||||
@@ -44,8 +47,10 @@ export class UmbIconPickerModalElement extends UmbModalBaseElement<UmbIconPicker
|
||||
if (!this.#icons) return;
|
||||
const value = this._searchInput?.value;
|
||||
if (value) {
|
||||
this._isSearching = value.length > 0;
|
||||
this._iconsFiltered = this.#icons.filter((icon) => icon.name.toLowerCase().includes(value.toLowerCase()));
|
||||
} else {
|
||||
this._isSearching = false;
|
||||
this._iconsFiltered = this.#icons;
|
||||
}
|
||||
}
|
||||
@@ -54,8 +59,12 @@ export class UmbIconPickerModalElement extends UmbModalBaseElement<UmbIconPicker
|
||||
const isActivate = e.type === 'click' || (e.type === 'keyup' && (e as KeyboardEvent).key === 'Enter');
|
||||
if (!isActivate) return;
|
||||
|
||||
const nextIcon = this.value.icon === iconName ? '' : iconName;
|
||||
this.modalContext?.updateValue({ icon: nextIcon });
|
||||
if (this.data?.showEmptyOption) {
|
||||
const nextIcon = this.value.icon === iconName ? '' : iconName;
|
||||
this.modalContext?.updateValue({ icon: nextIcon });
|
||||
} else {
|
||||
this.modalContext?.updateValue({ icon: iconName });
|
||||
}
|
||||
}
|
||||
|
||||
#onColorChange(e: UUIColorSwatchesEvent) {
|
||||
@@ -72,37 +81,23 @@ export class UmbIconPickerModalElement extends UmbModalBaseElement<UmbIconPicker
|
||||
<umb-body-layout headline=${this.localize.term('defaultdialogs_selectIcon')}>
|
||||
<div id="container">
|
||||
${this.renderSearch()}
|
||||
<hr />
|
||||
<uui-color-swatches
|
||||
value=${ifDefined(this.value.color)}
|
||||
label=${this.localize.term('defaultdialogs_colorSwitcher')}
|
||||
@change=${this.#onColorChange}>
|
||||
${
|
||||
// TODO: Missing localization for the color aliases. [NL]
|
||||
this._colorList.map(
|
||||
(color) => html`
|
||||
<uui-color-swatch
|
||||
label=${color.alias}
|
||||
title=${color.alias}
|
||||
value=${color.alias}
|
||||
style="--uui-swatch-color: var(${color.varName})">
|
||||
</uui-color-swatch>
|
||||
`,
|
||||
)
|
||||
}
|
||||
</uui-color-swatches>
|
||||
<hr />
|
||||
${this.renderColors()}
|
||||
<uui-scroll-container id="icons">
|
||||
<uui-button
|
||||
class=${!this.value.icon ? 'selected' : ''}
|
||||
label=${this.localize.term('defaultdialogs_noIcon')}
|
||||
title=${this.localize.term('defaultdialogs_noIcon')}
|
||||
@click=${this.#clearIcon}
|
||||
@keyup=${(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') this.#clearIcon();
|
||||
}}>
|
||||
<uui-icon style="opacity:.35" name=${ifDefined(this.data?.placeholder)}></uui-icon> </uui-button
|
||||
>${this.renderIcons()}</uui-scroll-container
|
||||
${this.data?.showEmptyOption && !this._isSearching
|
||||
? html`
|
||||
<uui-button
|
||||
class=${!this.value.icon ? 'selected' : ''}
|
||||
label=${this.localize.term('defaultdialogs_noIcon')}
|
||||
title=${this.localize.term('defaultdialogs_noIcon')}
|
||||
@click=${this.#clearIcon}
|
||||
@keyup=${(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') this.#clearIcon();
|
||||
}}>
|
||||
<uui-icon style="opacity:.35" name=${ifDefined(this.data?.placeholder)}></uui-icon>
|
||||
</uui-button>
|
||||
`
|
||||
: nothing}
|
||||
${this.renderIcons()}</uui-scroll-container
|
||||
>
|
||||
</div>
|
||||
<uui-button
|
||||
@@ -130,9 +125,34 @@ export class UmbIconPickerModalElement extends UmbModalBaseElement<UmbIconPicker
|
||||
${umbFocus()}>
|
||||
<uui-icon name="search" slot="prepend" id="search_icon"></uui-icon>
|
||||
</uui-input>
|
||||
<hr />
|
||||
`;
|
||||
}
|
||||
|
||||
renderColors() {
|
||||
return this.data?.hideColors === true
|
||||
? nothing
|
||||
: html`<uui-color-swatches
|
||||
value=${ifDefined(this.value.color)}
|
||||
label=${this.localize.term('defaultdialogs_colorSwitcher')}
|
||||
@change=${this.#onColorChange}>
|
||||
${
|
||||
this._colorList.map(
|
||||
(color) => html`
|
||||
<uui-color-swatch
|
||||
label=${color.alias}
|
||||
title=${color.alias}
|
||||
value=${color.alias}
|
||||
style="--uui-swatch-color: var(${color.varName})">
|
||||
</uui-color-swatch>
|
||||
`,
|
||||
)
|
||||
}
|
||||
</uui-color-swatches>
|
||||
<hr />
|
||||
`;
|
||||
}
|
||||
|
||||
renderIcons() {
|
||||
return this._iconsFiltered
|
||||
? repeat(
|
||||
|
||||
+2
@@ -2,6 +2,8 @@ import { UmbModalToken } from '@umbraco-cms/backoffice/modal';
|
||||
|
||||
export interface UmbIconPickerModalData {
|
||||
placeholder?: string;
|
||||
showEmptyOption?: boolean;
|
||||
hideColors?: boolean;
|
||||
}
|
||||
|
||||
export interface UmbIconPickerModalValue {
|
||||
|
||||
@@ -4,281 +4,335 @@ export const manifests: Array<ManifestLocalization> = [
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.AR',
|
||||
weight: 100,
|
||||
name: 'Arabic Backoffice UI Localization',
|
||||
meta: {
|
||||
culture: 'ar',
|
||||
},
|
||||
meta: { culture: 'ar' },
|
||||
js: () => import('../../../assets/lang/ar.js'),
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.BS',
|
||||
weight: 100,
|
||||
name: 'Bosnian Backoffice UI Localization',
|
||||
meta: {
|
||||
culture: 'bs',
|
||||
},
|
||||
meta: { culture: 'bs' },
|
||||
js: () => import('../../../assets/lang/bs.js'),
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.CS',
|
||||
weight: 100,
|
||||
name: 'Czech Backoffice UI Localization',
|
||||
meta: {
|
||||
culture: 'cs',
|
||||
},
|
||||
meta: { culture: 'cs' },
|
||||
js: () => import('../../../assets/lang/cs.js'),
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.CS_CZ',
|
||||
name: 'Czech (Czechia) Backoffice UI Localization',
|
||||
meta: { culture: 'cs-CZ' },
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.CY',
|
||||
weight: 100,
|
||||
name: 'Welsh Backoffice UI Localization',
|
||||
meta: {
|
||||
culture: 'cy',
|
||||
},
|
||||
meta: { culture: 'cy' },
|
||||
js: () => import('../../../assets/lang/cy.js'),
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.CY_GB',
|
||||
name: 'Welsh (UK) Backoffice UI Localization',
|
||||
meta: { culture: 'cy-GB' },
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.DA',
|
||||
weight: 100,
|
||||
name: 'Danish Backoffice UI Localization',
|
||||
meta: {
|
||||
culture: 'da',
|
||||
},
|
||||
meta: { culture: 'da' },
|
||||
js: () => import('../../../assets/lang/da.js'),
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.DA-DK',
|
||||
name: 'Danish (Denmark) Backoffice UI Localization',
|
||||
meta: { culture: 'da-DK' },
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.DE',
|
||||
weight: 100,
|
||||
name: 'German Backoffice UI Localization',
|
||||
meta: {
|
||||
culture: 'de',
|
||||
},
|
||||
meta: { culture: 'de' },
|
||||
js: () => import('../../../assets/lang/de.js'),
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.DE_DE',
|
||||
name: 'German (Germany) Backoffice UI Localization',
|
||||
meta: { culture: 'de-DE' },
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.DE_CH',
|
||||
name: 'German (Switzerland) Backoffice UI Localization',
|
||||
meta: { culture: 'de-CH' },
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.EN',
|
||||
weight: 100,
|
||||
name: 'English (United Kingdom) Backoffice UI Localization',
|
||||
meta: {
|
||||
culture: 'en',
|
||||
},
|
||||
meta: { culture: 'en' },
|
||||
js: () => import('../../../assets/lang/en.js'),
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.EN_US',
|
||||
weight: 100,
|
||||
name: 'English (United States) Backoffice UI Localization',
|
||||
meta: {
|
||||
culture: 'en-US',
|
||||
},
|
||||
meta: { culture: 'en-US' },
|
||||
js: () => import('../../../assets/lang/en-us.js'),
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.ES',
|
||||
weight: 100,
|
||||
name: 'Spanish Backoffice UI Localization',
|
||||
meta: {
|
||||
culture: 'es',
|
||||
},
|
||||
meta: { culture: 'es' },
|
||||
js: () => import('../../../assets/lang/es.js'),
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.ES_ES',
|
||||
name: 'Spanish (Spain) Backoffice UI Localization',
|
||||
meta: { culture: 'es-ES' },
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.FR',
|
||||
weight: 100,
|
||||
name: 'French Backoffice UI Localization',
|
||||
meta: {
|
||||
culture: 'fr',
|
||||
},
|
||||
meta: { culture: 'fr' },
|
||||
js: () => import('../../../assets/lang/fr.js'),
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.FR_FR',
|
||||
name: 'French (France) Backoffice UI Localization',
|
||||
meta: { culture: 'fr-FR' },
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.FR_CH',
|
||||
name: 'French (Switzerland) Backoffice UI Localization',
|
||||
meta: { culture: 'fr-CH' },
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.HE',
|
||||
weight: 100,
|
||||
name: 'Hebrew Backoffice UI Localization',
|
||||
meta: {
|
||||
culture: 'he',
|
||||
},
|
||||
meta: { culture: 'he' },
|
||||
js: () => import('../../../assets/lang/he.js'),
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.HE_IL',
|
||||
name: 'Hebrew (Israel) Backoffice UI Localization',
|
||||
meta: { culture: 'he-IL' },
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.HR',
|
||||
weight: 100,
|
||||
name: 'Croatian Backoffice UI Localization',
|
||||
meta: {
|
||||
culture: 'hr',
|
||||
},
|
||||
meta: { culture: 'hr' },
|
||||
js: () => import('../../../assets/lang/hr.js'),
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.HR_HR',
|
||||
name: 'Croatian (Croatia) Backoffice UI Localization',
|
||||
meta: { culture: 'hr-HR' },
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.IT',
|
||||
weight: 100,
|
||||
name: 'Italian Backoffice UI Localization',
|
||||
meta: {
|
||||
culture: 'it',
|
||||
},
|
||||
meta: { culture: 'it' },
|
||||
js: () => import('../../../assets/lang/it.js'),
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.IT_IT',
|
||||
name: 'Italian (Italy) Backoffice UI Localization',
|
||||
meta: { culture: 'it-IT' },
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.IT_CH',
|
||||
name: 'Italian (Switzerland) Backoffice UI Localization',
|
||||
meta: { culture: 'it-CH' },
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.JA',
|
||||
weight: 100,
|
||||
name: 'Japanese Backoffice UI Localization',
|
||||
meta: {
|
||||
culture: 'ja',
|
||||
},
|
||||
meta: { culture: 'ja' },
|
||||
js: () => import('../../../assets/lang/ja.js'),
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.JA_JP',
|
||||
name: 'Japanese (Japan) Backoffice UI Localization',
|
||||
meta: { culture: 'ja-JP' },
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.KO',
|
||||
weight: 100,
|
||||
name: 'Korean Backoffice UI Localization',
|
||||
meta: {
|
||||
culture: 'ko',
|
||||
},
|
||||
meta: { culture: 'ko' },
|
||||
js: () => import('../../../assets/lang/ko.js'),
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.KO_KR',
|
||||
name: 'Korean (Korea) Backoffice UI Localization',
|
||||
meta: { culture: 'ko-KR' },
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.NB',
|
||||
weight: 100,
|
||||
name: 'Norwegian Backoffice UI Localization',
|
||||
meta: {
|
||||
culture: 'nb',
|
||||
},
|
||||
meta: { culture: 'nb' },
|
||||
js: () => import('../../../assets/lang/nb.js'),
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.NB_NO',
|
||||
name: 'Norwegian (Norway) Backoffice UI Localization',
|
||||
meta: { culture: 'nb-NO' },
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.NL',
|
||||
weight: 100,
|
||||
name: 'Dutch Backoffice UI Localization',
|
||||
meta: {
|
||||
culture: 'nl',
|
||||
},
|
||||
meta: { culture: 'nl' },
|
||||
js: () => import('../../../assets/lang/nl.js'),
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.NL_NL',
|
||||
name: 'Dutch (Netherlands) Backoffice UI Localization',
|
||||
meta: { culture: 'nl-NL' },
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.PL',
|
||||
weight: 100,
|
||||
name: 'Polish Backoffice UI Localization',
|
||||
meta: {
|
||||
culture: 'pl',
|
||||
},
|
||||
meta: { culture: 'pl' },
|
||||
js: () => import('../../../assets/lang/pl.js'),
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.PL_PL',
|
||||
name: 'Polish (Poland) Backoffice UI Localization',
|
||||
meta: { culture: 'pl-PL' },
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.PT',
|
||||
weight: 100,
|
||||
name: 'Portuguese Backoffice UI Localization',
|
||||
meta: {
|
||||
culture: 'pt',
|
||||
},
|
||||
meta: { culture: 'pt' },
|
||||
js: () => import('../../../assets/lang/pt.js'),
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.PT_BR',
|
||||
weight: 100,
|
||||
name: 'Portuguese (Brazil) Backoffice UI Localization',
|
||||
meta: {
|
||||
culture: 'pt-BR',
|
||||
},
|
||||
meta: { culture: 'pt-BR' },
|
||||
js: () => import('../../../assets/lang/pt-br.js'),
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.RO',
|
||||
weight: 100,
|
||||
name: 'Romanian Backoffice UI Localization',
|
||||
meta: {
|
||||
culture: 'ro',
|
||||
},
|
||||
meta: { culture: 'ro' },
|
||||
js: () => import('../../../assets/lang/ro.js'),
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.RO_RO',
|
||||
name: 'Romanian (Romania) Backoffice UI Localization',
|
||||
meta: { culture: 'ro-RO' },
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.RU',
|
||||
weight: 100,
|
||||
name: 'Russian Backoffice UI Localization',
|
||||
meta: {
|
||||
culture: 'ru',
|
||||
},
|
||||
meta: { culture: 'ru' },
|
||||
js: () => import('../../../assets/lang/ru.js'),
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.RU_RU',
|
||||
name: 'Russian (Russia) Backoffice UI Localization',
|
||||
meta: { culture: 'ru-RU' },
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.SV',
|
||||
weight: 100,
|
||||
name: 'Swedish Backoffice UI Localization',
|
||||
meta: {
|
||||
culture: 'sv',
|
||||
},
|
||||
meta: { culture: 'sv' },
|
||||
js: () => import('../../../assets/lang/sv.js'),
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.SV_SE',
|
||||
name: 'Swedish (Sweden) Backoffice UI Localization',
|
||||
meta: { culture: 'sv-SE' },
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.TR',
|
||||
weight: 100,
|
||||
name: 'Turkish Backoffice UI Localization',
|
||||
meta: {
|
||||
culture: 'tr',
|
||||
},
|
||||
meta: { culture: 'tr' },
|
||||
js: () => import('../../../assets/lang/tr.js'),
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.TR_TR',
|
||||
name: 'Turkish (Türkiye) Backoffice UI Localization',
|
||||
meta: { culture: 'tr-TR' },
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.UK',
|
||||
weight: 100,
|
||||
name: 'Ukrainian Backoffice UI Localization',
|
||||
meta: {
|
||||
culture: 'uk',
|
||||
},
|
||||
meta: { culture: 'uk' },
|
||||
js: () => import('../../../assets/lang/uk.js'),
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.UK_UA',
|
||||
name: 'Ukrainian (Ukraine) Backoffice UI Localization',
|
||||
meta: { culture: 'uk-UA' },
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.ZH',
|
||||
weight: 100,
|
||||
name: 'Chinese Backoffice UI Localization',
|
||||
meta: {
|
||||
culture: 'zh',
|
||||
},
|
||||
meta: { culture: 'zh' },
|
||||
js: () => import('../../../assets/lang/zh.js'),
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.ZH_CN',
|
||||
name: 'Chinese (Simplified, China) Backoffice UI Localization',
|
||||
meta: { culture: 'zh-CN' },
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.ZH_TW',
|
||||
weight: 100,
|
||||
name: 'Chinese (Taiwan) Backoffice UI Localization',
|
||||
meta: {
|
||||
culture: 'zh-TW',
|
||||
},
|
||||
meta: { culture: 'zh-TW' },
|
||||
js: () => import('../../../assets/lang/zh-tw.js'),
|
||||
},
|
||||
{
|
||||
type: 'localization',
|
||||
alias: 'Umb.Localization.VI',
|
||||
weight: 100,
|
||||
name: 'Vietnamese Backoffice UI Localization',
|
||||
meta: {
|
||||
culture: 'vi',
|
||||
},
|
||||
meta: { culture: 'vi' },
|
||||
js: () => import('../../../assets/lang/vi.js'),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"@types/diff": "^7.0.2",
|
||||
"diff": "^7.0.0",
|
||||
"uuid": "^13.0.0",
|
||||
"@hey-api/openapi-ts": "0.85.0"
|
||||
"@hey-api/openapi-ts": "^0.85.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"lucide-static": "^0.544.0",
|
||||
|
||||
+1
@@ -44,6 +44,7 @@ function createXhrRequest<T>(options: XhrRequestOptions): UmbCancelablePromise<T
|
||||
return new UmbCancelablePromise<T>(async (resolve, reject, onCancel) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open(options.method, `${baseUrl}${options.url}`, true);
|
||||
xhr.withCredentials = options.withCredentials ?? true;
|
||||
|
||||
// Set default headers
|
||||
if (options.token) {
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface XhrRequestOptions extends UmbTryExecuteOptions {
|
||||
baseUrl?: string;
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';
|
||||
url: string;
|
||||
withCredentials?: boolean;
|
||||
body?: unknown;
|
||||
token?: string | (() => undefined | string | Promise<string | undefined>);
|
||||
headers?: Record<string, string>;
|
||||
|
||||
@@ -12,4 +12,5 @@ export interface ManifestSection
|
||||
export interface MetaSection {
|
||||
label: string;
|
||||
pathname: string;
|
||||
preventUrlRetention?: boolean;
|
||||
}
|
||||
|
||||
+1
-1
@@ -214,7 +214,7 @@ export class UmbInputDocumentElement extends UmbFormControlMixin<string | undefi
|
||||
}
|
||||
|
||||
#renderAddButton() {
|
||||
if (this.selection.length >= this.max) return nothing;
|
||||
if (this.selection.length > 0 && this.max === 1) return nothing;
|
||||
if (this.readonly && this.selection.length > 0) {
|
||||
return nothing;
|
||||
} else {
|
||||
|
||||
+1
@@ -22,6 +22,7 @@ export class UmbDocumentPermissionServerDataSource {
|
||||
this.#host,
|
||||
fetch(`/umbraco/management/api/v1/document/${id}/permissions`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
|
||||
+3
@@ -407,6 +407,7 @@ export class UmbInputRichMediaElement extends UmbFormControlMixin<
|
||||
|
||||
#renderAddButton() {
|
||||
if (this.readonly) return nothing;
|
||||
if (this.max === 1 && this._cards.length > 0) return nothing;
|
||||
return html`
|
||||
<uui-button
|
||||
id="btn-add"
|
||||
@@ -468,6 +469,8 @@ export class UmbInputRichMediaElement extends UmbFormControlMixin<
|
||||
css`
|
||||
:host {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.container {
|
||||
display: grid;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { getMimeTypeFromExtension } from '../../components/index.js';
|
||||
import { getMimeTypeFromExtension } from './utils.js';
|
||||
import type { ManifestFileUploadPreview } from './file-upload-preview.extension.js';
|
||||
import type { UmbFileUploadPreviewElement as UmbFileUploadPreviewElementInterface } from './file-upload-preview.interface.js';
|
||||
import { css, customElement, html, nothing, property, state } from '@umbraco-cms/backoffice/external/lit';
|
||||
|
||||
+3
@@ -16,9 +16,12 @@ export default class UmbInputUploadFieldSvgElement extends UmbLitElement impleme
|
||||
static override readonly styles = [
|
||||
css`
|
||||
:host {
|
||||
height: 100%;
|
||||
min-height: 240px;
|
||||
max-height: 400px;
|
||||
|
||||
width: fit-content;
|
||||
min-width: 240px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
|
||||
+22
-4
@@ -30,6 +30,8 @@ export class UmbDropzoneMediaElement extends UmbInputDropzoneElement {
|
||||
return this._progressItems;
|
||||
}
|
||||
|
||||
#dragCounter = 0;
|
||||
|
||||
protected override _manager = new UmbMediaDropzoneManager(this);
|
||||
public progressItems = () => this._manager.progressItems;
|
||||
public progress = () => this._manager.progress;
|
||||
@@ -39,6 +41,7 @@ export class UmbDropzoneMediaElement extends UmbInputDropzoneElement {
|
||||
|
||||
document.addEventListener('dragenter', this.#handleDragEnter.bind(this));
|
||||
document.addEventListener('dragleave', this.#handleDragLeave.bind(this));
|
||||
document.addEventListener('dragover', this.#handleDragOver.bind(this));
|
||||
document.addEventListener('drop', this.#handleDrop.bind(this));
|
||||
|
||||
// TODO: Revisit this. I am not sure why it is needed to call these methods here when they are already called in the constructor of the parent class.
|
||||
@@ -65,6 +68,7 @@ export class UmbDropzoneMediaElement extends UmbInputDropzoneElement {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('dragenter', this.#handleDragEnter.bind(this));
|
||||
document.removeEventListener('dragleave', this.#handleDragLeave.bind(this));
|
||||
document.removeEventListener('dragover', this.#handleDragOver.bind(this));
|
||||
document.removeEventListener('drop', this.#handleDrop.bind(this));
|
||||
}
|
||||
|
||||
@@ -78,21 +82,35 @@ export class UmbDropzoneMediaElement extends UmbInputDropzoneElement {
|
||||
|
||||
#handleDragEnter(e: DragEvent) {
|
||||
if (this.disabled) return;
|
||||
// Avoid collision with UmbSorterController
|
||||
const types = e.dataTransfer?.types;
|
||||
if (!types?.length || !types?.includes('Files')) return;
|
||||
|
||||
// Normalize types for Safari
|
||||
const types = Array.from(e.dataTransfer?.types || []).map((t) => t.toLowerCase());
|
||||
if (!types.includes('files')) return;
|
||||
|
||||
this.#dragCounter++;
|
||||
this.toggleAttribute('dragging', true);
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
#handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
#handleDragLeave() {
|
||||
if (this.disabled) return;
|
||||
this.toggleAttribute('dragging', false);
|
||||
|
||||
this.#dragCounter--;
|
||||
if (this.#dragCounter <= 0) {
|
||||
this.toggleAttribute('dragging', false);
|
||||
this.#dragCounter = 0;
|
||||
}
|
||||
}
|
||||
|
||||
#handleDrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
if (this.disabled) return;
|
||||
|
||||
this.#dragCounter = 0;
|
||||
this.toggleAttribute('dragging', false);
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -15,6 +15,8 @@ import { UMB_MODAL_MANAGER_CONTEXT, UmbModalBaseElement } from '@umbraco-cms/bac
|
||||
import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace';
|
||||
import type { UmbModalManagerContext } from '@umbraco-cms/backoffice/modal';
|
||||
|
||||
import '../../components/input-upload-field/file-upload-preview.element.js';
|
||||
|
||||
@customElement('umb-image-cropper-editor-modal')
|
||||
export class UmbImageCropperEditorModalElement extends UmbModalBaseElement<
|
||||
UmbImageCropperEditorModalData<any>,
|
||||
|
||||
+79
-19
@@ -1,8 +1,9 @@
|
||||
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
|
||||
import { css, html, customElement, state, query, unsafeHTML } from '@umbraco-cms/backoffice/external/lit';
|
||||
import { css, html, customElement, state, query, when } from '@umbraco-cms/backoffice/external/lit';
|
||||
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
|
||||
import { ProfilingService } from '@umbraco-cms/backoffice/external/backend-api';
|
||||
import { tryExecute } from '@umbraco-cms/backoffice/resources';
|
||||
import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification';
|
||||
|
||||
@customElement('umb-dashboard-performance-profiling')
|
||||
export class UmbDashboardPerformanceProfilingElement extends UmbLitElement {
|
||||
@@ -13,55 +14,114 @@ export class UmbDashboardPerformanceProfilingElement extends UmbLitElement {
|
||||
@state()
|
||||
private _isDebugMode = true;
|
||||
|
||||
@state()
|
||||
private _isLoading = true;
|
||||
|
||||
@query('#toggle')
|
||||
private _toggle!: HTMLInputElement;
|
||||
|
||||
private _notificationContext: typeof UMB_NOTIFICATION_CONTEXT.TYPE | undefined;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.consumeContext(UMB_NOTIFICATION_CONTEXT, (notificationContext) => {
|
||||
this._notificationContext = notificationContext;
|
||||
});
|
||||
}
|
||||
|
||||
#setToggle(value: boolean) {
|
||||
this._toggle.checked = value;
|
||||
this._profilingStatus = value;
|
||||
this._isLoading = false;
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
this._getProfilingStatus();
|
||||
override async firstUpdated() {
|
||||
const status = await this.#getProfilingStatus();
|
||||
this.#setToggle(status);
|
||||
}
|
||||
|
||||
private async _getProfilingStatus() {
|
||||
async #getProfilingStatus() {
|
||||
const { data } = await tryExecute(this, ProfilingService.getProfilingStatus());
|
||||
|
||||
if (!data) return;
|
||||
this._profilingStatus = data.enabled ?? false;
|
||||
return data?.enabled ?? false;
|
||||
}
|
||||
|
||||
private async _changeProfilingStatus() {
|
||||
const { error } = await tryExecute(
|
||||
this,
|
||||
ProfilingService.putProfilingStatus({ body: { enabled: !this._profilingStatus } }),
|
||||
);
|
||||
async #disableProfilingStatus() {
|
||||
this._isLoading = true;
|
||||
const { error } = await tryExecute(this, ProfilingService.putProfilingStatus({ body: { enabled: false } }));
|
||||
|
||||
if (error) {
|
||||
this.#setToggle(this._profilingStatus);
|
||||
} else {
|
||||
this.#setToggle(!this._profilingStatus);
|
||||
this.#setToggle(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Test that it was actually disabled
|
||||
const status = await this.#getProfilingStatus();
|
||||
|
||||
if (status) {
|
||||
this.#setToggle(true);
|
||||
this._notificationContext?.peek('warning', {
|
||||
data: {
|
||||
headline: this.localize.term('profiling_errorDisablingProfilerTitle'),
|
||||
message: this.localize.term('profiling_errorDisablingProfilerDescription'),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.#setToggle(false);
|
||||
}
|
||||
|
||||
async #enableProfilingStatus() {
|
||||
this._isLoading = true;
|
||||
const { error } = await tryExecute(this, ProfilingService.putProfilingStatus({ body: { enabled: true } }));
|
||||
|
||||
if (error) {
|
||||
this.#setToggle(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Test that it was actually enabled
|
||||
const status = await this.#getProfilingStatus();
|
||||
|
||||
if (!status) {
|
||||
this.#setToggle(false);
|
||||
this._notificationContext?.peek('warning', {
|
||||
data: {
|
||||
headline: this.localize.term('profiling_errorEnablingProfilerTitle'),
|
||||
message: this.localize.term('profiling_errorEnablingProfilerDescription'),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.#setToggle(true);
|
||||
}
|
||||
|
||||
#renderProfilingStatus() {
|
||||
return this._isDebugMode
|
||||
? html`
|
||||
${unsafeHTML(this.localize.term('profiling_performanceProfilingDescription'))}
|
||||
<umb-localize key="profiling_performanceProfilingDescription"></umb-localize>
|
||||
|
||||
<uui-toggle
|
||||
id="toggle"
|
||||
label=${this.localize.term('profiling_activateByDefault')}
|
||||
label-position="left"
|
||||
?checked="${this._profilingStatus}"
|
||||
@change="${this._changeProfilingStatus}"></uui-toggle>
|
||||
?disabled="${this._isLoading}"
|
||||
@change="${() =>
|
||||
this._profilingStatus ? this.#disableProfilingStatus() : this.#enableProfilingStatus()}"></uui-toggle>
|
||||
|
||||
<h4>${this.localize.term('profiling_reminder')}</h4>
|
||||
${when(this._isLoading, () => html`<uui-loader-circle></uui-loader-circle>`)}
|
||||
|
||||
${unsafeHTML(this.localize.term('profiling_reminderDescription'))}
|
||||
<h4>
|
||||
<umb-localize key="profiling_reminder"></umb-localize>
|
||||
</h4>
|
||||
|
||||
<umb-localize key="profiling_reminderDescription"></umb-localize>
|
||||
`
|
||||
: html` ${unsafeHTML(this.localize.term('profiling_profilerEnabledDescription'))} `;
|
||||
: html`<umb-localize key="profiling_profilerEnabledDescription"></umb-localize>`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
|
||||
@@ -16,6 +16,12 @@ export const manifests: Array<UmbExtensionManifest> = [
|
||||
description: 'Icon name to show when no icon is selected',
|
||||
propertyEditorUiAlias: 'Umb.PropertyEditorUi.IconPicker',
|
||||
},
|
||||
{
|
||||
alias: 'hideColors',
|
||||
label: 'Hide colors',
|
||||
description: 'Hide color swatches from modal',
|
||||
propertyEditorUiAlias: 'Umb.PropertyEditorUi.Toggle',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
+31
-10
@@ -9,28 +9,45 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
|
||||
import { extractUmbColorVariable } from '@umbraco-cms/backoffice/resources';
|
||||
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
|
||||
|
||||
import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation';
|
||||
|
||||
/**
|
||||
* @element umb-property-editor-ui-icon-picker
|
||||
*/
|
||||
@customElement('umb-property-editor-ui-icon-picker')
|
||||
export class UmbPropertyEditorUIIconPickerElement extends UmbLitElement implements UmbPropertyEditorUiElement {
|
||||
//
|
||||
export class UmbPropertyEditorUIIconPickerElement
|
||||
extends UmbFormControlMixin<string, typeof UmbLitElement, undefined>(UmbLitElement, undefined)
|
||||
implements UmbPropertyEditorUiElement
|
||||
{
|
||||
@property({ type: Boolean })
|
||||
mandatory = false;
|
||||
|
||||
protected override firstUpdated(): void {
|
||||
this.addValidator(
|
||||
'valueMissing',
|
||||
() => 'Icon is required',
|
||||
() => this.mandatory && !this._icon,
|
||||
);
|
||||
}
|
||||
|
||||
@property()
|
||||
public set value(v: string) {
|
||||
this._value = v ?? '';
|
||||
const parts = this._value.split(' ');
|
||||
public override set value(v: string) {
|
||||
const val = v ?? '';
|
||||
super.value = val;
|
||||
|
||||
const parts = val.split(' ');
|
||||
if (parts.length === 2) {
|
||||
this._icon = parts[0];
|
||||
this._color = parts[1].replace('color-', '');
|
||||
} else {
|
||||
this._icon = this._value;
|
||||
this._icon = val;
|
||||
this._color = '';
|
||||
}
|
||||
}
|
||||
public get value() {
|
||||
return this._value;
|
||||
|
||||
public override get value() {
|
||||
return (super.value as string) ?? '';
|
||||
}
|
||||
private _value = '';
|
||||
|
||||
@state()
|
||||
private _icon = '';
|
||||
@@ -41,10 +58,14 @@ export class UmbPropertyEditorUIIconPickerElement extends UmbLitElement implemen
|
||||
@state()
|
||||
private _placeholderIcon = '';
|
||||
|
||||
@state()
|
||||
private _hideColors = false;
|
||||
|
||||
public set config(config: UmbPropertyEditorConfigCollection | undefined) {
|
||||
if (!config) return;
|
||||
const placeholder = config.getValueByAlias('placeholder');
|
||||
this._placeholderIcon = typeof placeholder === 'string' ? placeholder : '';
|
||||
this._hideColors = config.getValueByAlias('hideColors') as boolean;
|
||||
}
|
||||
|
||||
private async _openModal() {
|
||||
@@ -53,7 +74,7 @@ export class UmbPropertyEditorUIIconPickerElement extends UmbLitElement implemen
|
||||
icon: this._icon,
|
||||
color: this._color,
|
||||
},
|
||||
data: { placeholder: this._placeholderIcon },
|
||||
data: { placeholder: this._placeholderIcon, showEmptyOption: !this.mandatory, hideColors: this._hideColors },
|
||||
}).catch(() => undefined);
|
||||
|
||||
if (!data) return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { UmbPropertyEditorRteValueType } from '../types.js';
|
||||
import { UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS } from '../constants.js';
|
||||
import { observeMultiple } from '@umbraco-cms/backoffice/observable-api';
|
||||
import { jsonStringComparison, observeMultiple } from '@umbraco-cms/backoffice/observable-api';
|
||||
import { property, state } from '@umbraco-cms/backoffice/external/lit';
|
||||
import { UmbBlockRteEntriesContext, UmbBlockRteManagerContext } from '@umbraco-cms/backoffice/block-rte';
|
||||
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
|
||||
@@ -243,9 +243,12 @@ export abstract class UmbPropertyEditorUiRteElementBase
|
||||
([layouts, contents, settings, exposes]) => {
|
||||
if (layouts.length === 0) {
|
||||
if (super.value?.markup === undefined) {
|
||||
if (this.value === undefined) {
|
||||
return;
|
||||
}
|
||||
super.value = undefined;
|
||||
} else {
|
||||
super.value = {
|
||||
const newValue = {
|
||||
...super.value,
|
||||
blocks: {
|
||||
layout: {},
|
||||
@@ -254,9 +257,13 @@ export abstract class UmbPropertyEditorUiRteElementBase
|
||||
expose: [],
|
||||
},
|
||||
};
|
||||
if (jsonStringComparison(this.value, newValue)) {
|
||||
return;
|
||||
}
|
||||
super.value = newValue;
|
||||
}
|
||||
} else {
|
||||
super.value = {
|
||||
const newValue = {
|
||||
markup: this._markup,
|
||||
blocks: {
|
||||
layout: { [UMB_BLOCK_RTE_PROPERTY_EDITOR_SCHEMA_ALIAS]: layouts },
|
||||
@@ -265,6 +272,10 @@ export abstract class UmbPropertyEditorUiRteElementBase
|
||||
expose: exposes,
|
||||
},
|
||||
};
|
||||
if (jsonStringComparison(this.value, newValue)) {
|
||||
return;
|
||||
}
|
||||
super.value = newValue;
|
||||
}
|
||||
|
||||
// If we don't have a value set from the outside or an internal value, we don't want to set the value.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { expect } from '@open-wc/testing';
|
||||
import { UmbMarked } from './ufm.context.js';
|
||||
|
||||
describe('UmbMarked sanitization', () => {
|
||||
describe('XSS prevention on custom elements', () => {
|
||||
it('should strip onclick from custom elements', async () => {
|
||||
const markup = await UmbMarked.parseInline('<uui-button onclick="alert(1)">Click</uui-button>');
|
||||
expect(markup).to.not.include('onclick');
|
||||
expect(markup).to.include('<uui-button>');
|
||||
});
|
||||
|
||||
it('should strip onload from custom elements', async () => {
|
||||
const markup = await UmbMarked.parseInline('<umb-test onload="alert(1)">Test</umb-test>');
|
||||
expect(markup).to.not.include('onload');
|
||||
expect(markup).to.include('<umb-test>');
|
||||
});
|
||||
|
||||
it('should strip onmouseover from custom elements', async () => {
|
||||
const markup = await UmbMarked.parseInline('<uui-box onmouseover="alert(1)">Hover</uui-box>');
|
||||
expect(markup).to.not.include('onmouseover');
|
||||
expect(markup).to.include('<uui-box>');
|
||||
});
|
||||
|
||||
it('should strip onfocus from custom elements', async () => {
|
||||
const markup = await UmbMarked.parseInline('<ufm-label-value onfocus="alert(1)" alias="test"></ufm-label-value>');
|
||||
expect(markup).to.not.include('onfocus');
|
||||
expect(markup).to.include('alias="test"');
|
||||
});
|
||||
|
||||
it('should strip onerror from custom elements', async () => {
|
||||
const markup = await UmbMarked.parseInline('<umb-test onerror="alert(1)">Test</umb-test>');
|
||||
expect(markup).to.not.include('onerror');
|
||||
});
|
||||
});
|
||||
|
||||
describe('safe attributes on custom elements', () => {
|
||||
it('should preserve class attribute', async () => {
|
||||
const markup = await UmbMarked.parseInline('<uui-button class="primary">Click</uui-button>');
|
||||
expect(markup).to.include('class="primary"');
|
||||
});
|
||||
|
||||
it('should preserve alias attribute', async () => {
|
||||
const markup = await UmbMarked.parseInline('<ufm-label-value alias="prop1"></ufm-label-value>');
|
||||
expect(markup).to.include('alias="prop1"');
|
||||
});
|
||||
|
||||
it('should preserve look attribute', async () => {
|
||||
const markup = await UmbMarked.parseInline('<uui-button look="primary">Click</uui-button>');
|
||||
expect(markup).to.include('look="primary"');
|
||||
});
|
||||
|
||||
it('should preserve slot attribute', async () => {
|
||||
const markup = await UmbMarked.parseInline('<umb-test slot="header">Test</umb-test>');
|
||||
expect(markup).to.include('slot="header"');
|
||||
});
|
||||
|
||||
it('should preserve label attribute', async () => {
|
||||
const markup = await UmbMarked.parseInline('<uui-button label="Submit">Click</uui-button>');
|
||||
expect(markup).to.include('label="Submit"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('standard HTML sanitization', () => {
|
||||
it('should strip script tags', async () => {
|
||||
const markup = await UmbMarked.parseInline('<script>alert(1)</script>');
|
||||
expect(markup).to.not.include('<script');
|
||||
});
|
||||
|
||||
it('should allow standard markdown bold', async () => {
|
||||
const markup = await UmbMarked.parseInline('**bold text**');
|
||||
expect(markup).to.include('<strong>bold text</strong>');
|
||||
});
|
||||
|
||||
it('should allow standard markdown links', async () => {
|
||||
const markup = await UmbMarked.parseInline('[link](https://example.com)');
|
||||
expect(markup).to.include('<a');
|
||||
expect(markup).to.include('href="https://example.com"');
|
||||
});
|
||||
|
||||
it('should strip non-allowed custom elements', async () => {
|
||||
const markup = await UmbMarked.parseInline('<custom-evil onclick="alert(1)">Test</custom-evil>');
|
||||
expect(markup).to.not.include('onclick');
|
||||
expect(markup).to.not.include('<custom-evil');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ const UmbDomPurifyConfig: DOMPurifyConfig = {
|
||||
USE_PROFILES: { html: true },
|
||||
CUSTOM_ELEMENT_HANDLING: {
|
||||
tagNameCheck: /^(?:ufm|umb|uui)-.*$/,
|
||||
attributeNameCheck: /.+/,
|
||||
attributeNameCheck: /^(?!on)/,
|
||||
allowCustomizedBuiltInElements: false,
|
||||
},
|
||||
};
|
||||
|
||||
+23
-159
File diff suppressed because it is too large
Load Diff
@@ -14,11 +14,11 @@
|
||||
"npm": ">=10.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.85.0",
|
||||
"@umbraco-cms/backoffice": "16.2.0",
|
||||
"@hey-api/openapi-ts": "^0.85.0",
|
||||
"@umbraco-cms/backoffice": "^16.2.0",
|
||||
"msw": "^2.11.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.1.9"
|
||||
"vite": "^7.1.11"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"generate-client": "node scripts/generate-openapi.js https://localhost:44339/umbraco/swagger/umbracoextension/swagger.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.85.0",
|
||||
"@hey-api/openapi-ts": "^0.85.0",
|
||||
"@umbraco-cms/backoffice": "^UMBRACO_VERSION_FROM_TEMPLATE",
|
||||
"chalk": "^5.6.2",
|
||||
"cross-env": "^10.1.0",
|
||||
|
||||
+4
-4
@@ -8,7 +8,7 @@
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@umbraco/json-models-builders": "^2.0.40",
|
||||
"@umbraco/playwright-testhelpers": "^16.0.55",
|
||||
"@umbraco/playwright-testhelpers": "^16.0.60",
|
||||
"camelize": "^1.0.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"node-fetch": "^2.6.7"
|
||||
@@ -67,9 +67,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@umbraco/playwright-testhelpers": {
|
||||
"version": "16.0.55",
|
||||
"resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-16.0.55.tgz",
|
||||
"integrity": "sha512-715l112FHB7snWq0sY7e0fUD2ppWSSysBKHFhcQkGGw+3Gbo68Z6iXfeAketzKohWji19un4KC3mvZU0IICr9g==",
|
||||
"version": "16.0.60",
|
||||
"resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-16.0.60.tgz",
|
||||
"integrity": "sha512-/6CS4YtsNN3vtahaG35xj5BNYlJMcIPKdiUBrmilWCYAFzxIT9u6EegWBtua46bQxcdeqQgUvIAghsNvDPEk1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@umbraco/json-models-builders": "2.0.40",
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@umbraco/json-models-builders": "^2.0.40",
|
||||
"@umbraco/playwright-testhelpers": "^16.0.55",
|
||||
"@umbraco/playwright-testhelpers": "^16.0.60",
|
||||
"camelize": "^1.0.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"node-fetch": "^2.6.7"
|
||||
|
||||
+19
@@ -417,3 +417,22 @@ test('can add a variant block element with invariant RTE Tiptap in the content',
|
||||
await umbracoApi.documentType.ensureNameNotExists(customElementTypeName);
|
||||
await umbracoApi.language.ensureNameNotExists('Danish');
|
||||
});
|
||||
|
||||
// Tests regression issue: https://github.com/umbraco/Umbraco-CMS/issues/20680
|
||||
test('can move away from a content node with a block grid after making no changes without seeing discard unsaved changes', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => {
|
||||
// Arrange
|
||||
const customDataTypeId = await umbracoApi.dataType.createBlockGridWithPermissions(customDataTypeName, elementTypeId, true, true);
|
||||
const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, customDataTypeName, customDataTypeId);
|
||||
await umbracoApi.document.createDefaultDocument(contentName, documentTypeId);
|
||||
await umbracoUi.goToBackOffice();
|
||||
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
|
||||
await umbracoUi.content.goToContentWithName(contentName);
|
||||
|
||||
// Act
|
||||
await umbracoUi.documentType.goToSection(ConstantHelper.sections.settings);
|
||||
|
||||
// Assert
|
||||
// We do this to make sure that there is no discard changes button visible, if the discard changes was visible, we would not be able to go to the document type
|
||||
await umbracoUi.documentType.goToDocumentType(documentTypeName);
|
||||
});
|
||||
|
||||
|
||||
+18
@@ -364,3 +364,21 @@ test('can add a variant block element with invariant RTE Tiptap in the content',
|
||||
await umbracoApi.documentType.ensureNameNotExists(customElementTypeName);
|
||||
await umbracoApi.language.ensureNameNotExists('Danish');
|
||||
});
|
||||
|
||||
// Tests regression issue: https://github.com/umbraco/Umbraco-CMS/issues/20680
|
||||
test('can move away from a content node with a block list after making no changes without seeing discard unsaved changes', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => {
|
||||
// Arrange
|
||||
const customDataTypeId = await umbracoApi.dataType.createBlockListDataTypeWithABlock(customDataTypeName, elementTypeId);
|
||||
const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, customDataTypeName, customDataTypeId);
|
||||
await umbracoApi.document.createDefaultDocument(contentName, documentTypeId);
|
||||
await umbracoUi.goToBackOffice();
|
||||
await umbracoUi.content.goToSection(ConstantHelper.sections.content);
|
||||
await umbracoUi.content.goToContentWithName(contentName);
|
||||
|
||||
// Act
|
||||
await umbracoUi.documentType.goToSection(ConstantHelper.sections.settings);
|
||||
|
||||
// Assert
|
||||
// We do this to make sure that there is no discard changes button visible, if the discard changes was visible, we would not be able to go to the document type
|
||||
await umbracoUi.documentType.goToDocumentType(documentTypeName);
|
||||
});
|
||||
|
||||
+2
-1
@@ -106,7 +106,8 @@ test('can copy and paste a single block into the same document but different gro
|
||||
await umbracoUi.content.doesBlockEditorBlockWithNameContainValue(elementGroupName, elementPropertyName, ConstantHelper.inputTypes.tipTap, blockPropertyValue);
|
||||
});
|
||||
|
||||
test('can copy and paste a single block into another document', async ({umbracoApi, umbracoUi}) => {
|
||||
// Remove skip after this issue is resolved: https://github.com/umbraco/Umbraco-CMS/issues/20680
|
||||
test.skip('can copy and paste a single block into another document', async ({umbracoApi, umbracoUi}) => {
|
||||
// Arrange
|
||||
await umbracoApi.document.ensureNameNotExists(secondContentName);
|
||||
await umbracoApi.document.createDefaultDocumentWithABlockGridEditorAndBlockWithValue(contentName, documentTypeName, blockGridDataTypeName, elementTypeId, AliasHelper.toAlias(elementPropertyName), blockPropertyValue, richTextDataTypeUiAlias);
|
||||
|
||||
+2
-1
@@ -106,7 +106,8 @@ test('can copy and paste a single block into the same document but different gro
|
||||
await umbracoUi.content.doesBlockEditorBlockWithNameContainValue(elementGroupName, elementPropertyName, ConstantHelper.inputTypes.tipTap, blockPropertyValue);
|
||||
});
|
||||
|
||||
test('can copy and paste a single block into another document', async ({umbracoApi, umbracoUi}) => {
|
||||
// Remove skip after this issue is resolved: https://github.com/umbraco/Umbraco-CMS/issues/20680
|
||||
test.skip('can copy and paste a single block into another document', async ({umbracoApi, umbracoUi}) => {
|
||||
// Arrange
|
||||
await umbracoApi.document.ensureNameNotExists(secondContentName);
|
||||
await umbracoApi.document.createDefaultDocumentWithABlockListEditorAndBlockWithValue(contentName, documentTypeName, blockListDataTypeName, elementTypeId, AliasHelper.toAlias(elementPropertyName), blockPropertyValue, elementDataTypeUiAlias, groupName);
|
||||
|
||||
@@ -234,8 +234,8 @@ test('can duplicate a content node to other parent', async ({umbracoApi, umbraco
|
||||
await umbracoUi.content.doesSuccessNotificationHaveText(NotificationConstantHelper.success.duplicated);
|
||||
await umbracoUi.content.isContentInTreeVisible(contentName);
|
||||
await umbracoUi.content.isContentInTreeVisible(parentContentName);
|
||||
await umbracoUi.content.openContentCaretButtonForName(parentContentName);
|
||||
await umbracoUi.content.isChildContentInTreeVisible(parentContentName, contentName);
|
||||
await umbracoUi.content.goToContentWithName(parentContentName);
|
||||
await umbracoUi.content.isContentWithNameVisibleInList(contentName);
|
||||
|
||||
// Clean
|
||||
await umbracoApi.document.ensureNameNotExists(parentContentName);
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ test('can publish content with the true/false data type', async ({umbracoApi, um
|
||||
expect(await umbracoApi.document.doesNameExist(contentName)).toBeTruthy();
|
||||
const contentData = await umbracoApi.document.getByName(contentName);
|
||||
expect(contentData.variants[0].state).toBe(expectedState);
|
||||
expect(contentData.values).toEqual([]);
|
||||
expect(contentData.values[0].value).toEqual(false);
|
||||
});
|
||||
|
||||
test('can toggle the true/false value in the content', {tag: '@release'}, async ({umbracoApi, umbracoUi}) => {
|
||||
|
||||
+7
-7
@@ -51,7 +51,7 @@ test('can create content using an invariant document blueprint', async ({umbraco
|
||||
await umbracoUi.content.clickActionsMenuAtRoot();
|
||||
await umbracoUi.content.clickCreateActionMenuOption();
|
||||
await umbracoUi.content.chooseDocumentType(documentTypeName);
|
||||
await umbracoUi.content.clickModalMenuItemWithName(documentBlueprintName);
|
||||
await umbracoUi.content.selectDocumentBlueprintWithName(documentBlueprintName);
|
||||
await umbracoUi.content.clickSaveButtonForContent();
|
||||
|
||||
// Assert
|
||||
@@ -75,7 +75,7 @@ test('can create content using a variant document blueprint', async ({umbracoApi
|
||||
await umbracoUi.content.clickActionsMenuAtRoot();
|
||||
await umbracoUi.content.clickCreateActionMenuOption();
|
||||
await umbracoUi.content.chooseDocumentType(documentTypeName);
|
||||
await umbracoUi.content.clickModalMenuItemWithName(documentBlueprintName);
|
||||
await umbracoUi.content.selectDocumentBlueprintWithName(documentBlueprintName);
|
||||
await umbracoUi.content.clickSaveButtonForContent();
|
||||
await umbracoUi.content.clickSaveButton();
|
||||
|
||||
@@ -104,7 +104,7 @@ test('can create content with different name using an invariant document bluepri
|
||||
await umbracoUi.content.clickActionsMenuAtRoot();
|
||||
await umbracoUi.content.clickCreateActionMenuOption();
|
||||
await umbracoUi.content.chooseDocumentType(documentTypeName);
|
||||
await umbracoUi.content.clickModalMenuItemWithName(documentBlueprintName);
|
||||
await umbracoUi.content.selectDocumentBlueprintWithName(documentBlueprintName);
|
||||
await umbracoUi.content.enterContentName(contentName);
|
||||
await umbracoUi.content.clickSaveButtonForContent();
|
||||
|
||||
@@ -130,7 +130,7 @@ test('can create content with different name using a variant document blueprint'
|
||||
await umbracoUi.content.clickActionsMenuAtRoot();
|
||||
await umbracoUi.content.clickCreateActionMenuOption();
|
||||
await umbracoUi.content.chooseDocumentType(documentTypeName);
|
||||
await umbracoUi.content.clickModalMenuItemWithName(documentBlueprintName);
|
||||
await umbracoUi.content.selectDocumentBlueprintWithName(documentBlueprintName);
|
||||
await umbracoUi.content.enterContentName(contentName);
|
||||
await umbracoUi.content.clickSaveButtonForContent();
|
||||
await umbracoUi.content.clickSaveButton();
|
||||
@@ -161,7 +161,7 @@ test('can create content using a document blueprint with block list', async ({um
|
||||
await umbracoUi.content.clickActionsMenuAtRoot();
|
||||
await umbracoUi.content.clickCreateActionMenuOption();
|
||||
await umbracoUi.content.chooseDocumentType(documentTypeName);
|
||||
await umbracoUi.content.clickModalMenuItemWithName(documentBlueprintName);
|
||||
await umbracoUi.content.selectDocumentBlueprintWithName(documentBlueprintName);
|
||||
await umbracoUi.content.clickSaveButtonForContent();
|
||||
|
||||
// Assert
|
||||
@@ -187,7 +187,7 @@ test('can create content using a document blueprint with block grid', async ({um
|
||||
await umbracoUi.content.clickActionsMenuAtRoot();
|
||||
await umbracoUi.content.clickCreateActionMenuOption();
|
||||
await umbracoUi.content.chooseDocumentType(documentTypeName);
|
||||
await umbracoUi.content.clickModalMenuItemWithName(documentBlueprintName);
|
||||
await umbracoUi.content.selectDocumentBlueprintWithName(documentBlueprintName);
|
||||
await umbracoUi.content.clickSaveButtonForContent();
|
||||
|
||||
// Assert
|
||||
@@ -197,4 +197,4 @@ test('can create content using a document blueprint with block grid', async ({um
|
||||
expect(contentData.values[0].value.contentData[0].values[0].value.markup).toEqual(textContent);
|
||||
const blockListValue = contentData.values.find(item => item.editorAlias === "Umbraco.BlockGrid")?.value;
|
||||
expect(blockListValue).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -275,7 +275,7 @@ test('max can not be less than min in a block grid editor', async ({umbracoApi,
|
||||
|
||||
// Assert
|
||||
await umbracoUi.dataType.isFailedStateButtonVisible();
|
||||
await umbracoUi.dataType.doesAmountContainErrorMessageWithText('The low value must not be exceed the high value');
|
||||
await umbracoUi.dataType.doesAmountContainErrorMessageWithText('The low value must not exceed the high value.');
|
||||
const dataTypeData = await umbracoApi.dataType.getByName(blockGridEditorName);
|
||||
expect(dataTypeData.values[0].value.min).toBe(minAmount);
|
||||
// The max value should not be updated
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user