Compare commits
186
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a98a47db0 | ||
|
|
18a9da080a | ||
|
|
6104efdd47 | ||
|
|
7d400e7742 | ||
|
|
8295f833f8 | ||
|
|
8d6645b0ef | ||
|
|
a0cb9b2826 | ||
|
|
b2ff910a92 | ||
|
|
b70d2b482d | ||
|
|
ac22eef92f | ||
|
|
24ef23e133 | ||
|
|
c8fb306e67 | ||
|
|
667c2f5a47 | ||
|
|
8230efd722 | ||
|
|
f420b0b894 | ||
|
|
b196ee89b7 | ||
|
|
b998afcd93 | ||
|
|
8c0bdb4b0b | ||
|
|
66d090b4e3 | ||
|
|
b779123946 | ||
|
|
ed36b31576 | ||
|
|
4871c0bab1 | ||
|
|
bfe567f075 | ||
|
|
328c43c225 | ||
|
|
56de595457 | ||
|
|
7505efd433 | ||
|
|
934126ae6e | ||
|
|
632f04afd2 | ||
|
|
edae01a152 | ||
|
|
d418f4f4d5 | ||
|
|
da677c42ed | ||
|
|
467db7736c | ||
|
|
aced6916ff | ||
|
|
dc0005f394 | ||
|
|
0da1146fcd | ||
|
|
a7b4481c6c | ||
|
|
90ec85386b | ||
|
|
9baaad40d1 | ||
|
|
7dcc7303c2 | ||
|
|
7edc6ec11d | ||
|
|
f2589b5af5 | ||
|
|
23b09b1a7e | ||
|
|
951f9a9e46 | ||
|
|
8949fd2d22 | ||
|
|
5d6f4f2135 | ||
|
|
21520eebd6 | ||
|
|
87e20ceec6 | ||
|
|
f7b874d3ef | ||
|
|
dedc77c883 | ||
|
|
8110b80318 | ||
|
|
9de905cba3 | ||
|
|
802fa70154 | ||
|
|
ebf397702d | ||
|
|
388e5f9239 | ||
|
|
d20cdd4930 | ||
|
|
a604b2053e | ||
|
|
c177c305ba | ||
|
|
e840a0c7c4 | ||
|
|
c9d5c7f95b | ||
|
|
4c9d02e90b | ||
|
|
d39395a1ff | ||
|
|
d45ddc315a | ||
|
|
7d87ce35bc | ||
|
|
d4805faee7 | ||
|
|
3deccdbcf3 | ||
|
|
9ad24ac2a7 | ||
|
|
0751728b05 | ||
|
|
e504011b76 | ||
|
|
4d9ea1e2e3 | ||
|
|
6796829908 | ||
|
|
67f7d99696 | ||
|
|
27e3184550 | ||
|
|
c98fd7b219 | ||
|
|
301be352ae | ||
|
|
7c180f6e4f | ||
|
|
e119186e34 | ||
|
|
a282002c30 | ||
|
|
a2c36d602f | ||
|
|
a45917f132 | ||
|
|
fe358b8eab | ||
|
|
60f84f7378 | ||
|
|
45f7b7ad98 | ||
|
|
85aa6d3b57 | ||
|
|
33e201c4b5 | ||
|
|
c2890e15c3 | ||
|
|
784e09e240 | ||
|
|
214f3fbc64 | ||
|
|
b622e7e4fb | ||
|
|
a352c42742 | ||
|
|
8eff28b80c | ||
|
|
9f37db18d1 | ||
|
|
0d94f8bfaf | ||
|
|
509a2e18d0 | ||
|
|
22f748161a | ||
|
|
59ad07209b | ||
|
|
67abecc252 | ||
|
|
ebd0017f6e | ||
|
|
417f15197e | ||
|
|
ce40103c4f | ||
|
|
2748fdfc48 | ||
|
|
53cc663bde | ||
|
|
13a2cd71c4 | ||
|
|
a60ccd389b | ||
|
|
fd95dc3915 | ||
|
|
990e379ea8 | ||
|
|
1f5c21c631 | ||
|
|
b4144564c8 | ||
|
|
4ad18dc963 | ||
|
|
881535af7d | ||
|
|
1e66fb6ab3 | ||
|
|
e1b9e5efad | ||
|
|
f6dbe0f33e | ||
|
|
28570b43e8 | ||
|
|
a01382d756 | ||
|
|
1d6e7f15b9 | ||
|
|
97cc3ca581 | ||
|
|
0bcae3e87b | ||
|
|
ebd228c3d7 | ||
|
|
4b83a74bdb | ||
|
|
b348b84b63 | ||
|
|
7f4a8d5974 | ||
|
|
4d8ca457ec | ||
|
|
d677e948f1 | ||
|
|
d4e6af50bd | ||
|
|
c609cafa9b | ||
|
|
5556b0fe0c | ||
|
|
eee04f4703 | ||
|
|
127c6c4b53 | ||
|
|
7d6a1e54e6 | ||
|
|
8433b2b637 | ||
|
|
87b2153794 | ||
|
|
0fc5b2a95c | ||
|
|
6c2f93e85d | ||
|
|
4f1604fea1 | ||
|
|
059766291b | ||
|
|
e471c1fc8b | ||
|
|
0fb91ef859 | ||
|
|
c0a0a34cbb | ||
|
|
3d44a6fdf9 | ||
|
|
0eee307622 | ||
|
|
6a4aa04b47 | ||
|
|
643f073945 | ||
|
|
5ba40c65fc | ||
|
|
dcbbed4160 | ||
|
|
e94e165593 | ||
|
|
34709be6cc | ||
|
|
83107bb31a | ||
|
|
3caa43a5bf | ||
|
|
1efe860a89 | ||
|
|
d49965549c | ||
|
|
8d85259025 | ||
|
|
097d0456cc | ||
|
|
a62fa93c77 | ||
|
|
a22dcf05f2 | ||
|
|
afa6fa5ca2 | ||
|
|
c6effef622 | ||
|
|
ab31fbb0aa | ||
|
|
7d9a2c6e70 | ||
|
|
a486d5df33 | ||
|
|
3c8a621817 | ||
|
|
0e0aca55af | ||
|
|
0f02584143 | ||
|
|
a3db45609a | ||
|
|
d60a2a217c | ||
|
|
3e9ff6b5cb | ||
|
|
05a7d337de | ||
|
|
fdca086a47 | ||
|
|
42a81beeac | ||
|
|
9284b9e0b1 | ||
|
|
eb91f4fef4 | ||
|
|
68acc2aa51 | ||
|
|
5570583f70 | ||
|
|
eb979625d1 | ||
|
|
e1e5f527e4 | ||
|
|
f016dbd223 | ||
|
|
b2aa04ae1a | ||
|
|
e270adc50f | ||
|
|
fd5b8de533 | ||
|
|
e3d9b042c2 | ||
|
|
f4ae796b30 | ||
|
|
367d0386b6 | ||
|
|
5b54bed406 | ||
|
|
d3b60277d7 | ||
|
|
2ec6ff4ebc | ||
|
|
451b5f96e7 | ||
|
|
15ebae5025 |
@@ -35,7 +35,7 @@
|
||||
<EnableStrictModeForCompatibleTfms>true</EnableStrictModeForCompatibleTfms>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Calculate version only once for the whole repository -->
|
||||
<!-- Calculate version only once for the whole repository -->
|
||||
<PropertyGroup>
|
||||
<GitVersionBaseDirectory>$(MSBuildThisFileDirectory)</GitVersionBaseDirectory>
|
||||
</PropertyGroup>
|
||||
|
||||
+21
-20
@@ -12,23 +12,24 @@
|
||||
</ItemGroup>
|
||||
<!-- Microsoft packages -->
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.11" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.11" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.22" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.22" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.10.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.10.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="4.10.0" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="8.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.22" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.22" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.FileProviders.Embedded" Version="8.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.FileProviders.Embedded" Version="8.0.22" />
|
||||
<PackageVersion Include="Microsoft.Extensions.FileProviders.Physical" Version="8.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="8.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="8.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="8.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="8.0.22" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="8.0.22" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="8.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="8.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
|
||||
@@ -45,9 +46,9 @@
|
||||
<PackageVersion Include="Asp.Versioning.Mvc" Version="7.1.1" />
|
||||
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="7.1.0" />
|
||||
<PackageVersion Include="Dazinator.Extensions.FileProviders" Version="2.0.0" />
|
||||
<PackageVersion Include="Examine" Version="3.5.0" />
|
||||
<PackageVersion Include="Examine.Core" Version="3.5.0" />
|
||||
<PackageVersion Include="HtmlAgilityPack" Version="1.11.71" />
|
||||
<PackageVersion Include="Examine" Version="3.8.0" />
|
||||
<PackageVersion Include="Examine.Core" Version="3.8.0" />
|
||||
<PackageVersion Include="HtmlAgilityPack" Version="1.11.74" />
|
||||
<PackageVersion Include="K4os.Compression.LZ4" Version="1.3.8" />
|
||||
<PackageVersion Include="MailKit" Version="4.8.0" />
|
||||
<PackageVersion Include="Markdown" Version="2.2.1" />
|
||||
@@ -55,7 +56,7 @@
|
||||
<PackageVersion Include="MiniProfiler.AspNetCore.Mvc" Version="4.3.8" />
|
||||
<PackageVersion Include="MiniProfiler.Shared" Version="4.3.8" />
|
||||
<PackageVersion Include="ncrontab" Version="3.3.3" />
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageVersion Include="NPoco" Version="5.7.1" />
|
||||
<PackageVersion Include="NPoco.SqlServer" Version="5.7.1" />
|
||||
<PackageVersion Include="OpenIddict.Abstractions" Version="4.10.1" />
|
||||
@@ -73,20 +74,20 @@
|
||||
<PackageVersion Include="Serilog.Sinks.Async" Version="1.5.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.File" Version="5.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Map" Version="1.0.2" />
|
||||
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.7" />
|
||||
<PackageVersion Include="SixLabors.ImageSharp.Web" Version="3.1.3" />
|
||||
<PackageVersion Include="Smidge.InMemory" Version="4.4.0" />
|
||||
<PackageVersion Include="Smidge.Nuglify" Version="4.5.1" />
|
||||
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||
<PackageVersion Include="SixLabors.ImageSharp.Web" Version="3.1.5" />
|
||||
<PackageVersion Include="Smidge.InMemory" Version="4.6.0" />
|
||||
<PackageVersion Include="Smidge.Nuglify" Version="4.6.0" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.9.0" />
|
||||
</ItemGroup>
|
||||
<!-- Transitive pinned versions (only required because our direct dependencies have vulnerable versions of transitive dependencies) -->
|
||||
<ItemGroup>
|
||||
<!-- Both Microsoft.EntityFrameworkCore.SqlServer and NPoco.SqlServer bring in a vulnerable version of Azure.Identity -->
|
||||
<PackageVersion Include="Azure.Identity" Version="1.13.1" />
|
||||
<PackageVersion Include="Azure.Identity" Version="1.13.2" />
|
||||
<!-- Dazinator.Extensions.FileProviders brings in a vulnerable version of System.Net.Http -->
|
||||
<PackageVersion Include="System.Net.Http" Version="4.3.4" />
|
||||
<!-- Examine brings in a vulnerable version of System.Security.Cryptography.Xml -->
|
||||
<PackageVersion Include="System.Security.Cryptography.Xml" Version="8.0.2" />
|
||||
<PackageVersion Include="System.Security.Cryptography.Xml" Version="8.0.3" />
|
||||
<!-- Both Dazinator.Extensions.FileProviders and MiniProfiler.AspNetCore.Mvc bring in a vulnerable version of System.Text.RegularExpressions -->
|
||||
<PackageVersion Include="System.Text.RegularExpressions" Version="4.3.1" />
|
||||
<!-- Both OpenIddict.AspNetCore, Npoco.SqlServer and Microsoft.EntityFrameworkCore.SqlServer bring in a vulnerable version of Microsoft.IdentityModel.JsonWebTokens -->
|
||||
@@ -96,8 +97,8 @@
|
||||
<!-- Both OpenIddict.AspNetCore, Microsoft.EntityFrameworkCore.* bring in a vulnerable version of Microsoft.Extensions.Caching.Memory -->
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
|
||||
<!-- Both Azure.Identity, Microsoft.EntityFrameworkCore.SqlServer,NPoco.SqlServer, and more bring in a vulnerable version of System.Text.Json -->
|
||||
<PackageVersion Include="System.Text.Json" Version="8.0.5" />
|
||||
<PackageVersion Include="System.Text.Json" Version="8.0.6" />
|
||||
<!-- Both Microsoft.EntityFrameworkCore.SqlServer and NPoco.SqlServer bring in a vulnerable version of Microsoft.Data.SqlClient -->
|
||||
<PackageVersion Include="Microsoft.Data.SqlClient" Version="5.2.2" />
|
||||
<PackageVersion Include="Microsoft.Data.SqlClient" Version="5.2.3" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
+116
-35
@@ -25,6 +25,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
|
||||
@@ -71,7 +75,7 @@ stages:
|
||||
- job: A
|
||||
displayName: Build Umbraco CMS
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
vmImage: 'windows-latest'
|
||||
steps:
|
||||
- checkout: self
|
||||
submodules: false
|
||||
@@ -93,6 +97,11 @@ stages:
|
||||
- script: npm ci --no-fund --no-audit --prefer-offline
|
||||
workingDirectory: src/Umbraco.Web.UI.Client
|
||||
displayName: Run npm ci (Backoffice)
|
||||
- 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
|
||||
- task: gulp@0
|
||||
displayName: Run gulp build (Backoffice)
|
||||
inputs:
|
||||
@@ -106,6 +115,10 @@ stages:
|
||||
workingDir: src/Umbraco.Web.UI.Login
|
||||
verbose: false
|
||||
customCommand: ci
|
||||
- powershell: |
|
||||
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: npm@1
|
||||
displayName: Run npm build (Login)
|
||||
inputs:
|
||||
@@ -129,6 +142,10 @@ 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'
|
||||
- task: PublishPipelineArtifact@1
|
||||
displayName: Publish nupkg
|
||||
inputs:
|
||||
@@ -139,6 +156,35 @@ stages:
|
||||
inputs:
|
||||
targetPath: $(Build.SourcesDirectory)
|
||||
artifactName: build_output
|
||||
- task: PublishPipelineArtifact@1
|
||||
displayName: Publish Backend BOM
|
||||
inputs:
|
||||
targetPath: $(Build.ArtifactStagingDirectory)/bom
|
||||
artifactName: bom-build
|
||||
|
||||
- stage: E2E_BOM
|
||||
displayName: E2E Tests BOM Generation
|
||||
dependsOn: []
|
||||
jobs:
|
||||
- job:
|
||||
displayName: E2E Generate BOM
|
||||
pool:
|
||||
vmImage: "ubuntu-latest"
|
||||
steps:
|
||||
- checkout: self
|
||||
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}}))
|
||||
@@ -338,7 +384,9 @@ stages:
|
||||
# Integration Tests (SQL Server)
|
||||
- job:
|
||||
timeoutInMinutes: 120
|
||||
condition: or(eq(stageDependencies.Build.A.outputs['build.NBGV_PublicRelease'], 'True'), ${{parameters.sqlServerIntegrationTests}})
|
||||
# We are currently encountering issues when running SQL Server Linux tests Microsoft.Data.SqlClient.SqlException (0x80131904)
|
||||
# condition: or(eq(stageDependencies.Build.A.outputs['build.NBGV_PublicRelease'], 'True'), ${{parameters.sqlServerIntegrationTests}})
|
||||
condition: eq(${{parameters.sqlServerIntegrationTests}}, True)
|
||||
displayName: Integration Tests (SQL Server)
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -462,37 +510,18 @@ stages:
|
||||
artifact: nupkg
|
||||
path: $(Agent.BuildDirectory)/app/nupkg
|
||||
|
||||
- task: NodeTool@0
|
||||
displayName: Use Node.js $(nodeVersion)
|
||||
retryCountOnTaskFailure: 3
|
||||
inputs:
|
||||
versionSpec: $(nodeVersion)
|
||||
|
||||
- task: UseDotNet@2
|
||||
displayName: Use .NET SDK from global.json
|
||||
inputs:
|
||||
useGlobalJson: true
|
||||
|
||||
- pwsh: |
|
||||
"UMBRACO_USER_LOGIN=$(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSEREMAIL)
|
||||
UMBRACO_USER_PASSWORD=$(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD)
|
||||
URL=$(ASPNETCORE_URLS)" | 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: $(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: $(nodeVersion)
|
||||
npm_config_cache: $(npm_config_cache)
|
||||
PlaywrightUserEmail: $(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSEREMAIL)
|
||||
PlaywrightPassword: $(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD)
|
||||
ASPNETCORE_URLS: $(ASPNETCORE_URLS)
|
||||
|
||||
# Build application
|
||||
- pwsh: |
|
||||
@@ -529,8 +558,8 @@ stages:
|
||||
workingDirectory: tests/Umbraco.Tests.AcceptanceTest
|
||||
|
||||
# Install Playwright and dependencies
|
||||
- pwsh: npx playwright install --with-deps
|
||||
displayName: Install Playwright
|
||||
- pwsh: npx playwright install chromium
|
||||
displayName: Install Playwright only with Chromium browser
|
||||
workingDirectory: tests/Umbraco.Tests.AcceptanceTest
|
||||
|
||||
# Test
|
||||
@@ -560,13 +589,24 @@ stages:
|
||||
displayName: Copy Playwright results
|
||||
condition: succeededOrFailed()
|
||||
|
||||
# Publish
|
||||
# Publish test artifacts
|
||||
- task: PublishPipelineArtifact@1
|
||||
displayName: Publish test artifacts
|
||||
condition: succeededOrFailed()
|
||||
inputs:
|
||||
targetPath: $(Build.ArtifactStagingDirectory)
|
||||
artifact: "Acceptance Test Results - $(Agent.JobName) - Attempt #$(System.JobAttempt)"
|
||||
|
||||
# Publish test results
|
||||
- task: PublishTestResults@2
|
||||
displayName: "Publish test results"
|
||||
condition: succeededOrFailed()
|
||||
inputs:
|
||||
testResultsFormat: 'JUnit'
|
||||
testResultsFiles: '*.xml'
|
||||
searchFolder: "tests/Umbraco.Tests.AcceptanceTest/results"
|
||||
testRunTitle: "$(Agent.JobName)"
|
||||
|
||||
- job:
|
||||
displayName: E2E Tests (SQL Server)
|
||||
condition: or(eq(stageDependencies.Build.A.outputs['build.NBGV_PublicRelease'], 'True'), ${{parameters.sqlServerAcceptanceTests}})
|
||||
@@ -685,14 +725,13 @@ stages:
|
||||
workingDirectory: tests/Umbraco.Tests.AcceptanceTest
|
||||
|
||||
# Install Playwright and dependencies
|
||||
- pwsh: npx playwright install --with-deps
|
||||
displayName: Install Playwright
|
||||
- pwsh: npx playwright install chromium
|
||||
displayName: Install Playwright only with Chromium browser
|
||||
workingDirectory: tests/Umbraco.Tests.AcceptanceTest
|
||||
|
||||
# Test
|
||||
- pwsh: $(testCommand)
|
||||
displayName: Run Playwright tests
|
||||
continueOnError: true
|
||||
workingDirectory: tests/Umbraco.Tests.AcceptanceTest
|
||||
env:
|
||||
CI: true
|
||||
@@ -725,7 +764,7 @@ stages:
|
||||
displayName: Copy Playwright results
|
||||
condition: succeededOrFailed()
|
||||
|
||||
# Publish
|
||||
# Publish test artifacts
|
||||
- task: PublishPipelineArtifact@1
|
||||
displayName: Publish test artifacts
|
||||
condition: succeededOrFailed()
|
||||
@@ -733,6 +772,44 @@ stages:
|
||||
targetPath: $(Build.ArtifactStagingDirectory)
|
||||
artifact: "Acceptance Test Results - $(Agent.JobName) - Attempt #$(System.JobAttempt)"
|
||||
|
||||
# Publish test results
|
||||
- task: PublishTestResults@2
|
||||
displayName: "Publish test results"
|
||||
condition: succeededOrFailed()
|
||||
inputs:
|
||||
testResultsFormat: 'JUnit'
|
||||
testResultsFiles: '*.xml'
|
||||
searchFolder: "tests/Umbraco.Tests.AcceptanceTest/results"
|
||||
testRunTitle: "$(Agent.JobName)"
|
||||
|
||||
- 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-build"
|
||||
bomFilePath: "bom-dotnet.xml"
|
||||
- name: "Login"
|
||||
artifact: "bom-build"
|
||||
bomFilePath: "bom-login.xml"
|
||||
- name: "Backoffice"
|
||||
artifact: "bom-build"
|
||||
bomFilePath: "bom-backoffice.xml"
|
||||
- name: "E2E"
|
||||
artifact: "bom-e2e"
|
||||
bomFilePath: "bom-e2e.xml"
|
||||
|
||||
###############################################
|
||||
## Release
|
||||
###############################################
|
||||
@@ -745,6 +822,8 @@ stages:
|
||||
condition: and(succeeded(), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.myGetDeploy}}))
|
||||
jobs:
|
||||
- job:
|
||||
pool:
|
||||
vmImage: "windows-latest" # NuGetCommand@2 is no longer supported on Ubuntu 24.04 so we'll use windows until an alternative is available.
|
||||
displayName: Push to pre-release feed
|
||||
steps:
|
||||
- checkout: none
|
||||
@@ -771,6 +850,8 @@ stages:
|
||||
condition: and(succeeded(), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.nuGetDeploy}}))
|
||||
jobs:
|
||||
- job:
|
||||
pool:
|
||||
vmImage: "windows-latest" # NuGetCommand@2 is no longer supported on Ubuntu 24.04 so we'll use windows until an alternative is available.
|
||||
displayName: Push to NuGet
|
||||
steps:
|
||||
- checkout: none
|
||||
|
||||
@@ -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,47 @@
|
||||
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 }}" | 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": "8.0.100",
|
||||
"version": "8.0.415",
|
||||
"rollForward": "latestFeature"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Umbraco.Cms.Core.DeliveryApi;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
@@ -7,9 +8,13 @@ namespace Umbraco.Cms.Api.Delivery.Caching;
|
||||
internal sealed class DeliveryApiOutputCachePolicy : IOutputCachePolicy
|
||||
{
|
||||
private readonly TimeSpan _duration;
|
||||
private readonly StringValues _varyByHeaderNames;
|
||||
|
||||
public DeliveryApiOutputCachePolicy(TimeSpan duration)
|
||||
=> _duration = duration;
|
||||
public DeliveryApiOutputCachePolicy(TimeSpan duration, StringValues varyByHeaderNames)
|
||||
{
|
||||
_duration = duration;
|
||||
_varyByHeaderNames = varyByHeaderNames;
|
||||
}
|
||||
|
||||
ValueTask IOutputCachePolicy.CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -18,8 +23,14 @@ internal sealed class DeliveryApiOutputCachePolicy : IOutputCachePolicy
|
||||
.RequestServices
|
||||
.GetRequiredService<IRequestPreviewService>();
|
||||
|
||||
context.EnableOutputCaching = requestPreviewService.IsPreview() is false;
|
||||
IApiAccessService apiAccessService = context
|
||||
.HttpContext
|
||||
.RequestServices
|
||||
.GetRequiredService<IApiAccessService>();
|
||||
|
||||
context.EnableOutputCaching = requestPreviewService.IsPreview() is false && apiAccessService.HasPublicAccess();
|
||||
context.ResponseExpirationTimeSpan = _duration;
|
||||
context.CacheVaryByRules.HeaderNames = _varyByHeaderNames;
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ public class ByRouteContentApiController : ContentApiItemControllerBase
|
||||
private readonly IRequestRedirectService _requestRedirectService;
|
||||
private readonly IRequestPreviewService _requestPreviewService;
|
||||
private readonly IRequestMemberAccessService _requestMemberAccessService;
|
||||
private const string PreviewContentRequestPathPrefix = $"/{Constants.DeliveryApi.Routing.PreviewContentPathPrefix}";
|
||||
private const string PreviewContentRequestPathPrefix = $"/{Umbraco.Cms.Core.Constants.DeliveryApi.Routing.PreviewContentPathPrefix}";
|
||||
|
||||
[Obsolete($"Please use the constructor that does not accept {nameof(IPublicAccessService)}. Will be removed in V14.")]
|
||||
public ByRouteContentApiController(
|
||||
@@ -145,6 +145,11 @@ public class ByRouteContentApiController : ContentApiItemControllerBase
|
||||
path = DecodePath(path);
|
||||
path = path.Length == 0 ? "/" : path;
|
||||
|
||||
if (_apiContentPathResolver.IsResolvablePath(path) is false)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
IPublishedContent? contentItem = GetContent(path);
|
||||
if (contentItem is not null)
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Umbraco.Cms.Api.Common.DependencyInjection;
|
||||
using Umbraco.Cms.Api.Delivery.Accessors;
|
||||
using Umbraco.Cms.Api.Delivery.Caching;
|
||||
@@ -33,26 +34,33 @@ public static class UmbracoBuilderExtensions
|
||||
builder.Services.AddScoped<IRequestStartItemProvider, RequestStartItemProvider>();
|
||||
builder.Services.AddScoped<RequestContextOutputExpansionStrategy>();
|
||||
builder.Services.AddScoped<RequestContextOutputExpansionStrategyV2>();
|
||||
builder.Services.AddScoped<IOutputExpansionStrategy>(provider =>
|
||||
{
|
||||
HttpContext? httpContext = provider.GetRequiredService<IHttpContextAccessor>().HttpContext;
|
||||
ApiVersion? apiVersion = httpContext?.GetRequestedApiVersion();
|
||||
if (apiVersion is null)
|
||||
{
|
||||
return provider.GetRequiredService<RequestContextOutputExpansionStrategyV2>();
|
||||
}
|
||||
|
||||
// V1 of the Delivery API uses a different expansion strategy than V2+
|
||||
return apiVersion.MajorVersion == 1
|
||||
? provider.GetRequiredService<RequestContextOutputExpansionStrategy>()
|
||||
: provider.GetRequiredService<RequestContextOutputExpansionStrategyV2>();
|
||||
});
|
||||
builder.Services.AddUnique<IOutputExpansionStrategy>(
|
||||
provider =>
|
||||
{
|
||||
HttpContext? httpContext = provider.GetRequiredService<IHttpContextAccessor>().HttpContext;
|
||||
ApiVersion? apiVersion = httpContext?.GetRequestedApiVersion();
|
||||
if (apiVersion is null)
|
||||
{
|
||||
return provider.GetRequiredService<RequestContextOutputExpansionStrategyV2>();
|
||||
}
|
||||
|
||||
// V1 of the Delivery API uses a different expansion strategy than V2+
|
||||
return apiVersion.MajorVersion == 1
|
||||
? provider.GetRequiredService<RequestContextOutputExpansionStrategy>()
|
||||
: provider.GetRequiredService<RequestContextOutputExpansionStrategyV2>();
|
||||
},
|
||||
ServiceLifetime.Scoped);
|
||||
|
||||
builder.Services.AddSingleton<IRequestCultureService, RequestCultureService>();
|
||||
builder.Services.AddSingleton<IRequestRoutingService, RequestRoutingService>();
|
||||
builder.Services.AddSingleton<IRequestRedirectService, RequestRedirectService>();
|
||||
builder.Services.AddSingleton<IRequestPreviewService, RequestPreviewService>();
|
||||
builder.Services.AddSingleton<IOutputExpansionStrategyAccessor, RequestContextOutputExpansionStrategyAccessor>();
|
||||
|
||||
// Webooks register a more basic implementation, remove it.
|
||||
builder.Services.AddUnique<IOutputExpansionStrategyAccessor, RequestContextOutputExpansionStrategyAccessor>(ServiceLifetime.Singleton);
|
||||
builder.Services.AddSingleton<IRequestStartItemProviderAccessor, RequestContextRequestStartItemProviderAccessor>();
|
||||
|
||||
builder.Services.AddSingleton<IApiAccessService, ApiAccessService>();
|
||||
builder.Services.AddSingleton<IApiContentQueryService, ApiContentQueryService>();
|
||||
builder.Services.AddSingleton<IApiContentQueryProvider, ApiContentQueryProvider>();
|
||||
@@ -108,12 +116,20 @@ public static class UmbracoBuilderExtensions
|
||||
|
||||
if (outputCacheSettings.ContentDuration.TotalSeconds > 0)
|
||||
{
|
||||
options.AddPolicy(Constants.DeliveryApi.OutputCache.ContentCachePolicy, new DeliveryApiOutputCachePolicy(outputCacheSettings.ContentDuration));
|
||||
options.AddPolicy(
|
||||
Constants.DeliveryApi.OutputCache.ContentCachePolicy,
|
||||
new DeliveryApiOutputCachePolicy(
|
||||
outputCacheSettings.ContentDuration,
|
||||
new StringValues([Constants.DeliveryApi.HeaderNames.AcceptLanguage, Constants.DeliveryApi.HeaderNames.StartItem])));
|
||||
}
|
||||
|
||||
if (outputCacheSettings.MediaDuration.TotalSeconds > 0)
|
||||
{
|
||||
options.AddPolicy(Constants.DeliveryApi.OutputCache.MediaCachePolicy, new DeliveryApiOutputCachePolicy(outputCacheSettings.MediaDuration));
|
||||
options.AddPolicy(
|
||||
Constants.DeliveryApi.OutputCache.MediaCachePolicy,
|
||||
new DeliveryApiOutputCachePolicy(
|
||||
outputCacheSettings.MediaDuration,
|
||||
Constants.DeliveryApi.HeaderNames.StartItem));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.OpenApi.Any;
|
||||
using Microsoft.OpenApi.Any;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
using Umbraco.Cms.Api.Delivery.Configuration;
|
||||
@@ -21,7 +21,7 @@ internal sealed class SwaggerContentDocumentationFilter : SwaggerDocumentationFi
|
||||
|
||||
operation.Parameters.Add(new OpenApiParameter
|
||||
{
|
||||
Name = "Accept-Language",
|
||||
Name = Core.Constants.DeliveryApi.HeaderNames.AcceptLanguage,
|
||||
In = ParameterLocation.Header,
|
||||
Required = false,
|
||||
Description = "Defines the language to return. Use this when querying language variant content items.",
|
||||
@@ -37,7 +37,7 @@ internal sealed class SwaggerContentDocumentationFilter : SwaggerDocumentationFi
|
||||
|
||||
operation.Parameters.Add(new OpenApiParameter
|
||||
{
|
||||
Name = "Preview",
|
||||
Name = Core.Constants.DeliveryApi.HeaderNames.Preview,
|
||||
In = ParameterLocation.Header,
|
||||
Required = false,
|
||||
Description = "Whether to request draft content.",
|
||||
@@ -46,7 +46,7 @@ internal sealed class SwaggerContentDocumentationFilter : SwaggerDocumentationFi
|
||||
|
||||
operation.Parameters.Add(new OpenApiParameter
|
||||
{
|
||||
Name = "Start-Item",
|
||||
Name = Core.Constants.DeliveryApi.HeaderNames.StartItem,
|
||||
In = ParameterLocation.Header,
|
||||
Required = false,
|
||||
Description = "URL segment or GUID of a root content item.",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.OpenApi.Any;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
@@ -63,7 +63,7 @@ internal abstract class SwaggerDocumentationFilterBase<TBaseController>
|
||||
protected void AddApiKey(OpenApiOperation operation) =>
|
||||
operation.Parameters.Add(new OpenApiParameter
|
||||
{
|
||||
Name = "Api-Key",
|
||||
Name = Core.Constants.DeliveryApi.HeaderNames.ApiKey,
|
||||
In = ParameterLocation.Header,
|
||||
Required = false,
|
||||
Description = "API key specified through configuration to authorize access to the API.",
|
||||
|
||||
+48
-21
@@ -6,6 +6,7 @@ using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
using Umbraco.Cms.Infrastructure.Security;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Handlers;
|
||||
@@ -16,16 +17,21 @@ internal sealed class InitializeMemberApplicationNotificationHandler : INotifica
|
||||
private readonly ILogger<InitializeMemberApplicationNotificationHandler> _logger;
|
||||
private readonly DeliveryApiSettings _deliveryApiSettings;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private readonly IServerRoleAccessor _serverRoleAccessor;
|
||||
private static readonly SemaphoreSlim _locker = new(1);
|
||||
private static bool _isInitialized = false;
|
||||
|
||||
public InitializeMemberApplicationNotificationHandler(
|
||||
IRuntimeState runtimeState,
|
||||
IOptions<DeliveryApiSettings> deliveryApiSettings,
|
||||
ILogger<InitializeMemberApplicationNotificationHandler> logger,
|
||||
IServiceScopeFactory serviceScopeFactory)
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
IServerRoleAccessor serverRoleAccessor)
|
||||
{
|
||||
_runtimeState = runtimeState;
|
||||
_logger = logger;
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_serverRoleAccessor = serverRoleAccessor;
|
||||
_deliveryApiSettings = deliveryApiSettings.Value;
|
||||
}
|
||||
|
||||
@@ -36,34 +42,55 @@ internal sealed class InitializeMemberApplicationNotificationHandler : INotifica
|
||||
return;
|
||||
}
|
||||
|
||||
// we cannot inject the IMemberApplicationManager because it ultimately takes a dependency on the DbContext ... and during
|
||||
// install that is not allowed (no connection string means no DbContext)
|
||||
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
||||
IMemberApplicationManager memberApplicationManager = scope.ServiceProvider.GetRequiredService<IMemberApplicationManager>();
|
||||
|
||||
if (_deliveryApiSettings.MemberAuthorization?.AuthorizationCodeFlow?.Enabled is not true)
|
||||
if (_serverRoleAccessor.CurrentServerRole is ServerRole.Subscriber)
|
||||
{
|
||||
await memberApplicationManager.DeleteMemberApplicationAsync(cancellationToken);
|
||||
// subscriber instances should not alter the member application
|
||||
return;
|
||||
}
|
||||
|
||||
if (ValidateRedirectUrls(_deliveryApiSettings.MemberAuthorization.AuthorizationCodeFlow.LoginRedirectUrls) is false)
|
||||
try
|
||||
{
|
||||
await memberApplicationManager.DeleteMemberApplicationAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
await _locker.WaitAsync(cancellationToken);
|
||||
if (_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_deliveryApiSettings.MemberAuthorization.AuthorizationCodeFlow.LogoutRedirectUrls.Any()
|
||||
&& ValidateRedirectUrls(_deliveryApiSettings.MemberAuthorization.AuthorizationCodeFlow.LogoutRedirectUrls) is false)
|
||||
_isInitialized = true;
|
||||
|
||||
// we cannot inject the IMemberApplicationManager because it ultimately takes a dependency on the DbContext ... and during
|
||||
// install that is not allowed (no connection string means no DbContext)
|
||||
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
||||
IMemberApplicationManager memberApplicationManager = scope.ServiceProvider.GetRequiredService<IMemberApplicationManager>();
|
||||
|
||||
if (_deliveryApiSettings.MemberAuthorization?.AuthorizationCodeFlow?.Enabled is not true)
|
||||
{
|
||||
await memberApplicationManager.DeleteMemberApplicationAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ValidateRedirectUrls(_deliveryApiSettings.MemberAuthorization.AuthorizationCodeFlow.LoginRedirectUrls) is false)
|
||||
{
|
||||
await memberApplicationManager.DeleteMemberApplicationAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_deliveryApiSettings.MemberAuthorization.AuthorizationCodeFlow.LogoutRedirectUrls.Any()
|
||||
&& ValidateRedirectUrls(_deliveryApiSettings.MemberAuthorization.AuthorizationCodeFlow.LogoutRedirectUrls) is false)
|
||||
{
|
||||
await memberApplicationManager.DeleteMemberApplicationAsync(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await memberApplicationManager.EnsureMemberApplicationAsync(
|
||||
_deliveryApiSettings.MemberAuthorization.AuthorizationCodeFlow.LoginRedirectUrls,
|
||||
_deliveryApiSettings.MemberAuthorization.AuthorizationCodeFlow.LogoutRedirectUrls,
|
||||
cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await memberApplicationManager.DeleteMemberApplicationAsync(cancellationToken);
|
||||
return;
|
||||
_locker.Release();
|
||||
}
|
||||
|
||||
await memberApplicationManager.EnsureMemberApplicationAsync(
|
||||
_deliveryApiSettings.MemberAuthorization.AuthorizationCodeFlow.LoginRedirectUrls,
|
||||
_deliveryApiSettings.MemberAuthorization.AuthorizationCodeFlow.LogoutRedirectUrls,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private bool ValidateRedirectUrls(Uri[] redirectUrls)
|
||||
|
||||
@@ -1,62 +1,24 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core.DeliveryApi;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Extensions;
|
||||
using Umbraco.Cms.Web.Common.Rendering;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Rendering;
|
||||
|
||||
internal sealed class RequestContextOutputExpansionStrategyV2 : IOutputExpansionStrategy
|
||||
internal sealed class RequestContextOutputExpansionStrategyV2 : ElementOnlyOutputExpansionStrategy, IOutputExpansionStrategy
|
||||
{
|
||||
private const string All = "$all";
|
||||
private const string None = "";
|
||||
private const string ExpandParameterName = "expand";
|
||||
private const string FieldsParameterName = "fields";
|
||||
|
||||
private readonly IApiPropertyRenderer _propertyRenderer;
|
||||
private readonly ILogger<RequestContextOutputExpansionStrategyV2> _logger;
|
||||
|
||||
private readonly Stack<Node?> _expandProperties;
|
||||
private readonly Stack<Node?> _includeProperties;
|
||||
|
||||
public RequestContextOutputExpansionStrategyV2(
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IApiPropertyRenderer propertyRenderer,
|
||||
ILogger<RequestContextOutputExpansionStrategyV2> logger)
|
||||
: base(propertyRenderer)
|
||||
{
|
||||
_propertyRenderer = propertyRenderer;
|
||||
_logger = logger;
|
||||
_expandProperties = new Stack<Node?>();
|
||||
_includeProperties = new Stack<Node?>();
|
||||
|
||||
InitializeExpandAndInclude(httpContextAccessor);
|
||||
}
|
||||
|
||||
public IDictionary<string, object?> MapContentProperties(IPublishedContent content)
|
||||
=> content.ItemType == PublishedItemType.Content
|
||||
? MapProperties(content.Properties)
|
||||
: throw new ArgumentException($"Invalid item type. This method can only be used with item type {nameof(PublishedItemType.Content)}, got: {content.ItemType}");
|
||||
|
||||
public IDictionary<string, object?> MapMediaProperties(IPublishedContent media, bool skipUmbracoProperties = true)
|
||||
{
|
||||
if (media.ItemType != PublishedItemType.Media)
|
||||
{
|
||||
throw new ArgumentException($"Invalid item type. This method can only be used with item type {PublishedItemType.Media}, got: {media.ItemType}");
|
||||
}
|
||||
|
||||
IPublishedProperty[] properties = media
|
||||
.Properties
|
||||
.Where(p => skipUmbracoProperties is false || p.Alias.StartsWith("umbraco") is false)
|
||||
.ToArray();
|
||||
|
||||
return properties.Any()
|
||||
? MapProperties(properties)
|
||||
: new Dictionary<string, object?>();
|
||||
}
|
||||
|
||||
public IDictionary<string, object?> MapElementProperties(IPublishedElement element)
|
||||
=> MapProperties(element.Properties, true);
|
||||
|
||||
private void InitializeExpandAndInclude(IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
string? QueryValue(string key) => httpContextAccessor.HttpContext?.Request.Query[key];
|
||||
@@ -66,7 +28,7 @@ internal sealed class RequestContextOutputExpansionStrategyV2 : IOutputExpansion
|
||||
|
||||
try
|
||||
{
|
||||
_expandProperties.Push(Node.Parse(toExpand));
|
||||
ExpandProperties.Push(Node.Parse(toExpand));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
@@ -76,7 +38,7 @@ internal sealed class RequestContextOutputExpansionStrategyV2 : IOutputExpansion
|
||||
|
||||
try
|
||||
{
|
||||
_includeProperties.Push(Node.Parse(toInclude));
|
||||
IncludeProperties.Push(Node.Parse(toInclude));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
@@ -84,102 +46,4 @@ internal sealed class RequestContextOutputExpansionStrategyV2 : IOutputExpansion
|
||||
throw new ArgumentException($"Could not parse the '{FieldsParameterName}' parameter: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private IDictionary<string, object?> MapProperties(IEnumerable<IPublishedProperty> properties, bool forceExpandProperties = false)
|
||||
{
|
||||
Node? currentExpandProperties = _expandProperties.Peek();
|
||||
if (_expandProperties.Count > 1 && currentExpandProperties is null && forceExpandProperties is false)
|
||||
{
|
||||
return new Dictionary<string, object?>();
|
||||
}
|
||||
|
||||
Node? currentIncludeProperties = _includeProperties.Peek();
|
||||
var result = new Dictionary<string, object?>();
|
||||
foreach (IPublishedProperty property in properties)
|
||||
{
|
||||
Node? nextIncludeProperties = GetNextProperties(currentIncludeProperties, property.Alias);
|
||||
if (currentIncludeProperties is not null && currentIncludeProperties.Items.Any() && nextIncludeProperties is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Node? nextExpandProperties = GetNextProperties(currentExpandProperties, property.Alias);
|
||||
|
||||
_includeProperties.Push(nextIncludeProperties);
|
||||
_expandProperties.Push(nextExpandProperties);
|
||||
|
||||
result[property.Alias] = GetPropertyValue(property);
|
||||
|
||||
_expandProperties.Pop();
|
||||
_includeProperties.Pop();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private Node? GetNextProperties(Node? currentProperties, string propertyAlias)
|
||||
=> currentProperties?.Items.FirstOrDefault(i => i.Key == All)
|
||||
?? currentProperties?.Items.FirstOrDefault(i => i.Key == "properties")?.Items.FirstOrDefault(i => i.Key == All || i.Key == propertyAlias);
|
||||
|
||||
private object? GetPropertyValue(IPublishedProperty property)
|
||||
=> _propertyRenderer.GetPropertyValue(property, _expandProperties.Peek() is not null);
|
||||
|
||||
private class Node
|
||||
{
|
||||
public string Key { get; private set; } = string.Empty;
|
||||
|
||||
public List<Node> Items { get; } = new();
|
||||
|
||||
public static Node Parse(string value)
|
||||
{
|
||||
// verify that there are as many start brackets as there are end brackets
|
||||
if (value.CountOccurrences("[") != value.CountOccurrences("]"))
|
||||
{
|
||||
throw new ArgumentException("Value did not contain an equal number of start and end brackets");
|
||||
}
|
||||
|
||||
// verify that the value does not start with a start bracket
|
||||
if (value.StartsWith("["))
|
||||
{
|
||||
throw new ArgumentException("Value cannot start with a bracket");
|
||||
}
|
||||
|
||||
// verify that there are no empty brackets
|
||||
if (value.Contains("[]"))
|
||||
{
|
||||
throw new ArgumentException("Value cannot contain empty brackets");
|
||||
}
|
||||
|
||||
var stack = new Stack<Node>();
|
||||
var root = new Node { Key = "root" };
|
||||
stack.Push(root);
|
||||
|
||||
var currentNode = new Node();
|
||||
root.Items.Add(currentNode);
|
||||
|
||||
foreach (char c in value)
|
||||
{
|
||||
switch (c)
|
||||
{
|
||||
case '[': // Start a new node, child of the current node
|
||||
stack.Push(currentNode);
|
||||
currentNode = new Node();
|
||||
stack.Peek().Items.Add(currentNode);
|
||||
break;
|
||||
case ',': // Start a new node, but at the same level of the current node
|
||||
currentNode = new Node();
|
||||
stack.Peek().Items.Add(currentNode);
|
||||
break;
|
||||
case ']': // Back to parent of the current node
|
||||
currentNode = stack.Pop();
|
||||
break;
|
||||
default: // Add char to current node key
|
||||
currentNode.Key += c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.DeliveryApi;
|
||||
@@ -29,7 +29,7 @@ internal sealed class ApiAccessService : RequestHeaderHandler, IApiAccessService
|
||||
private bool IfEnabled(Func<bool> condition) => _deliveryApiSettings.Enabled && condition();
|
||||
|
||||
private bool HasValidApiKey() => _deliveryApiSettings.ApiKey.IsNullOrWhiteSpace() == false
|
||||
&& _deliveryApiSettings.ApiKey.Equals(GetHeaderValue("Api-Key"));
|
||||
&& _deliveryApiSettings.ApiKey.Equals(GetHeaderValue(Core.Constants.DeliveryApi.HeaderNames.ApiKey));
|
||||
|
||||
private bool IfMediaEnabled(Func<bool> condition) => _deliveryApiSettings is { Enabled: true, Media.Enabled: true } && condition();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Umbraco.Cms.Core.DeliveryApi;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Services;
|
||||
@@ -11,5 +11,5 @@ internal sealed class RequestPreviewService : RequestHeaderHandler, IRequestPrev
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsPreview() => GetHeaderValue("Preview") == "true";
|
||||
public bool IsPreview() => string.Equals(GetHeaderValue(Core.Constants.DeliveryApi.HeaderNames.Preview), "true", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
@@ -58,5 +58,5 @@ internal sealed class RequestStartItemProvider : RequestHeaderHandler, IRequestS
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string? RequestedStartItem() => GetHeaderValue("Start-Item");
|
||||
public string? RequestedStartItem() => GetHeaderValue(Constants.DeliveryApi.HeaderNames.StartItem);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ internal abstract class RoutingServiceBase
|
||||
}
|
||||
|
||||
protected static string GetContentRoute(DomainAndUri domainAndUri, Uri contentRoute)
|
||||
=> $"{domainAndUri.ContentId}{DomainUtilities.PathRelativeToDomain(domainAndUri.Uri, contentRoute.AbsolutePath)}";
|
||||
=> $"{domainAndUri.ContentId}{DomainUtilities.PathRelativeToDomain(domainAndUri.Uri, contentRoute.LocalPath)}"; // Use LocalPath over AbsolutePath to keep the path decoded.
|
||||
|
||||
protected DomainAndUri? GetDomainAndUriForRoute(Uri contentUrl)
|
||||
{
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>Umbraco.Tests.UnitTests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>Umbraco.Tests.Integration</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>DynamicProxyGenAssembly2</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="SixLabors.ImageSharp" VersionOverride="[2.1.10, 3)" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" VersionOverride="[2.1.11, 3)" />
|
||||
<PackageReference Include="SixLabors.ImageSharp.Web" VersionOverride="[2.0.2, 3)" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -127,10 +127,16 @@ internal class EFCoreScope<TDbContext> : CoreScope, IEfCoreScope<TDbContext>
|
||||
|
||||
Locks.ClearLocks(InstanceId);
|
||||
|
||||
if (ParentScope is null)
|
||||
// Since we can nest EFCoreScopes in other scopes derived from CoreScope, we should check whether our ParentScope OR the base ParentScope exists.
|
||||
// Only if neither do do we take responsibility for ensuring the locks are cleared.
|
||||
// Eventually the highest parent will clear the locks.
|
||||
// Further, these locks are a reference to the locks of the highest parent anyway (see the constructor of CoreScope).
|
||||
#pragma warning disable SA1100 // Do not prefix calls with base unless local implementation exists (justification: provides additional clarify here that this is defined on the base class).
|
||||
if (ParentScope is null && base.HasParentScope is false)
|
||||
{
|
||||
Locks.EnsureLocksCleared(InstanceId);
|
||||
}
|
||||
#pragma warning restore SA1100 // Do not prefix calls with base unless local implementation exists
|
||||
|
||||
_efCoreScopeProvider.PopAmbientScope();
|
||||
|
||||
|
||||
@@ -48,6 +48,13 @@ public static class AppCacheExtensions
|
||||
return default;
|
||||
}
|
||||
|
||||
// If we've retrieved the specific string that represents null in the cache, return it only if we are requesting it (via a typed request for a string).
|
||||
// Otherwise consider it a null value.
|
||||
if (RetrievedNullRepresentationInCache(result))
|
||||
{
|
||||
return RequestedNullRepresentationInCache<T>() ? (T)result : default;
|
||||
}
|
||||
|
||||
return result.TryConvertTo<T>().Result;
|
||||
}
|
||||
|
||||
@@ -59,6 +66,18 @@ public static class AppCacheExtensions
|
||||
return default;
|
||||
}
|
||||
|
||||
// If we've retrieved the specific string that represents null in the cache, return it only if we are requesting it (via a typed request for a string).
|
||||
// Otherwise consider it a null value.
|
||||
if (RetrievedNullRepresentationInCache(result))
|
||||
{
|
||||
return RequestedNullRepresentationInCache<T>() ? (T)result : default;
|
||||
}
|
||||
|
||||
return result.TryConvertTo<T>().Result;
|
||||
}
|
||||
|
||||
private static bool RetrievedNullRepresentationInCache(object result) => result == (object)Cms.Core.Constants.Cache.NullRepresentationInCache;
|
||||
|
||||
private static bool RequestedNullRepresentationInCache<T>() => typeof(T) == typeof(string);
|
||||
|
||||
}
|
||||
|
||||
@@ -22,4 +22,6 @@ public static class CacheKeys
|
||||
|
||||
public const string ContentRecycleBinCacheKey = "recycleBin_content";
|
||||
public const string MediaRecycleBinCacheKey = "recycleBin_media";
|
||||
|
||||
public const string MemberUserNameCachePrefix = "uRepo_userNameKey+";
|
||||
}
|
||||
|
||||
@@ -159,15 +159,30 @@ public static class DistributedCacheExtensions
|
||||
=> dc.RefreshMemberCache(members.AsEnumerable());
|
||||
|
||||
public static void RefreshMemberCache(this DistributedCache dc, IEnumerable<IMember> members)
|
||||
=> dc.RefreshByPayload(MemberCacheRefresher.UniqueId, members.DistinctBy(x => (x.Id, x.Username)).Select(x => new MemberCacheRefresher.JsonPayload(x.Id, x.Username, false)));
|
||||
|
||||
=> dc.RefreshByPayload(
|
||||
MemberCacheRefresher.UniqueId,
|
||||
GetPayloads(members, false));
|
||||
|
||||
[Obsolete("Use the overload accepting IEnumerable instead to avoid allocating arrays. This overload will be removed in Umbraco 13.")]
|
||||
public static void RemoveMemberCache(this DistributedCache dc, params IMember[] members)
|
||||
=> dc.RemoveMemberCache(members.AsEnumerable());
|
||||
|
||||
public static void RemoveMemberCache(this DistributedCache dc, IEnumerable<IMember> members)
|
||||
=> dc.RefreshByPayload(MemberCacheRefresher.UniqueId, members.DistinctBy(x => (x.Id, x.Username)).Select(x => new MemberCacheRefresher.JsonPayload(x.Id, x.Username, true)));
|
||||
=> dc.RefreshByPayload(
|
||||
MemberCacheRefresher.UniqueId,
|
||||
GetPayloads(members, true));
|
||||
|
||||
// Internal for unit test.
|
||||
internal static IEnumerable<MemberCacheRefresher.JsonPayload> GetPayloads(IEnumerable<IMember> members, bool removed)
|
||||
=> members
|
||||
.DistinctBy(x => (x.Id, x.Username))
|
||||
.Select(x => new MemberCacheRefresher.JsonPayload(x.Id, x.Username, removed)
|
||||
{
|
||||
PreviousUsername = x.HasAdditionalData &&
|
||||
x.AdditionalData!.TryGetValue(Cms.Core.Constants.Entities.AdditionalDataKeys.MemberPreviousUserName, out var previousUsername)
|
||||
? previousUsername?.ToString()
|
||||
: null,
|
||||
});
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
namespace Umbraco.Cms.Core.Cache.PartialViewCacheInvalidators;
|
||||
|
||||
/// <summary>
|
||||
/// Defines behaviours for clearing of cached partials views that are configured to be cached individually by member.
|
||||
/// </summary>
|
||||
public interface IMemberPartialViewCacheInvalidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Clears the partial view cache items for the specified member ids.
|
||||
/// </summary>
|
||||
/// <param name="memberIds">The member Ids to clear the cache for.</param>
|
||||
/// <remarks>
|
||||
/// Called from the <see cref="MemberCacheRefresher"/> when a member is saved or deleted.
|
||||
/// </remarks>
|
||||
void ClearPartialViewCacheItems(IEnumerable<int> memberIds);
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
namespace Umbraco.Cms.Core.Cache.PartialViewCacheInvalidators;
|
||||
|
||||
internal class NoopMemberPartialViewCacheInvalidator : IMemberPartialViewCacheInvalidator
|
||||
{
|
||||
public void ClearPartialViewCacheItems(IEnumerable<int> memberIds)
|
||||
{
|
||||
// No operation performed, this is a no-op implementation.
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
// using Newtonsoft.Json;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Cache.PartialViewCacheInvalidators;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
@@ -15,10 +18,37 @@ public sealed class MemberCacheRefresher : PayloadCacheRefresherBase<MemberCache
|
||||
public static readonly Guid UniqueId = Guid.Parse("E285DF34-ACDC-4226-AE32-C0CB5CF388DA");
|
||||
|
||||
private readonly IIdKeyMap _idKeyMap;
|
||||
private readonly IMemberPartialViewCacheInvalidator _memberPartialViewCacheInvalidator;
|
||||
|
||||
public MemberCacheRefresher(AppCaches appCaches, IJsonSerializer serializer, IIdKeyMap idKeyMap, IEventAggregator eventAggregator, ICacheRefresherNotificationFactory factory)
|
||||
: base(appCaches, serializer, eventAggregator, factory) =>
|
||||
[Obsolete("Use the non obsoleted constructor instead. Scheduled for removal in v17")]
|
||||
public MemberCacheRefresher(
|
||||
AppCaches appCaches,
|
||||
IJsonSerializer serializer,
|
||||
IIdKeyMap idKeyMap,
|
||||
IEventAggregator eventAggregator,
|
||||
ICacheRefresherNotificationFactory factory)
|
||||
: this(
|
||||
appCaches,
|
||||
serializer,
|
||||
idKeyMap,
|
||||
eventAggregator,
|
||||
factory,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IMemberPartialViewCacheInvalidator>())
|
||||
{
|
||||
}
|
||||
|
||||
public MemberCacheRefresher(
|
||||
AppCaches appCaches,
|
||||
IJsonSerializer serializer,
|
||||
IIdKeyMap idKeyMap,
|
||||
IEventAggregator eventAggregator,
|
||||
ICacheRefresherNotificationFactory factory,
|
||||
IMemberPartialViewCacheInvalidator memberPartialViewCacheInvalidator)
|
||||
: base(appCaches, serializer, eventAggregator, factory)
|
||||
{
|
||||
_idKeyMap = idKeyMap;
|
||||
_memberPartialViewCacheInvalidator = memberPartialViewCacheInvalidator;
|
||||
}
|
||||
|
||||
#region Indirect
|
||||
|
||||
@@ -40,6 +70,8 @@ public sealed class MemberCacheRefresher : PayloadCacheRefresherBase<MemberCache
|
||||
|
||||
public string? Username { get; }
|
||||
|
||||
public string? PreviousUsername { get; set; }
|
||||
|
||||
public bool Removed { get; }
|
||||
}
|
||||
|
||||
@@ -67,16 +99,36 @@ public sealed class MemberCacheRefresher : PayloadCacheRefresherBase<MemberCache
|
||||
|
||||
private void ClearCache(params JsonPayload[] payloads)
|
||||
{
|
||||
AppCaches.ClearPartialViewCache();
|
||||
// Clear the partial views cache for all partials that are cached by member, for the updates members.
|
||||
_memberPartialViewCacheInvalidator.ClearPartialViewCacheItems(payloads.Select(p => p.Id));
|
||||
|
||||
Attempt<IAppPolicyCache?> memberCache = AppCaches.IsolatedCaches.Get<IMember>();
|
||||
|
||||
foreach (JsonPayload p in payloads)
|
||||
{
|
||||
_idKeyMap.ClearCache(p.Id);
|
||||
if (memberCache.Success)
|
||||
if (memberCache.Success is false)
|
||||
{
|
||||
memberCache.Result?.Clear(RepositoryCacheKeys.GetKey<IMember, int>(p.Id));
|
||||
memberCache.Result?.Clear(RepositoryCacheKeys.GetKey<IMember, string>(p.Username));
|
||||
continue;
|
||||
}
|
||||
|
||||
memberCache.Result?.Clear(RepositoryCacheKeys.GetKey<IMember, int>(p.Id));
|
||||
memberCache.Result?.Clear(RepositoryCacheKeys.GetKey<IMember, string>(p.Username));
|
||||
|
||||
// This specific cache key was introduced to fix an issue where the member username could not be the same as the member id, because the cache keys collided.
|
||||
// This is done in a bit of a hacky way, because the cache key is created internally in the repository, but we need to clear it here.
|
||||
// Ideally, we want to use a shared way of generating the key between this and the repository.
|
||||
// Additionally, the RepositoryCacheKeys actually caches the string to avoid re-allocating memory; we would like to also use this in the repository
|
||||
// See:
|
||||
// https://github.com/umbraco/Umbraco-CMS/pull/17350
|
||||
// https://github.com/umbraco/Umbraco-CMS/pull/17815
|
||||
memberCache.Result?.Clear(RepositoryCacheKeys.GetKey<IMember, string>(CacheKeys.MemberUserNameCachePrefix + p.Username));
|
||||
|
||||
// If provided, clear the cache by the previous user name too.
|
||||
if (string.IsNullOrEmpty(p.PreviousUsername) is false)
|
||||
{
|
||||
memberCache.Result?.Clear(RepositoryCacheKeys.GetKey<IMember, string>(p.PreviousUsername));
|
||||
memberCache.Result?.Clear(RepositoryCacheKeys.GetKey<IMember, string>(CacheKeys.MemberUserNameCachePrefix + p.PreviousUsername));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,15 @@ public class ValueEditorCache : IValueEditorCache
|
||||
{
|
||||
foreach (Dictionary<int, IDataValueEditor> editors in _valueEditorCache.Values)
|
||||
{
|
||||
editors.Remove(id);
|
||||
if (editors.TryGetValue(id, out IDataValueEditor? editor))
|
||||
{
|
||||
if (editor is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
|
||||
editors.Remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// See LICENSE for more details.
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Umbraco.Cms.Core.Configuration.Models;
|
||||
|
||||
@@ -27,6 +28,8 @@ public class SecuritySettings
|
||||
|
||||
internal const int StaticMemberDefaultLockoutTimeInMinutes = 30 * 24 * 60;
|
||||
internal const int StaticUserDefaultLockoutTimeInMinutes = 30 * 24 * 60;
|
||||
internal const long StaticUserDefaultFailedLoginDurationInMilliseconds = 1000;
|
||||
internal const long StaticUserMinimumFailedLoginDurationInMilliseconds = 250;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to keep the user logged in.
|
||||
@@ -125,4 +128,28 @@ public class SecuritySettings
|
||||
/// </summary>
|
||||
[DefaultValue(StaticAllowConcurrentLogins)]
|
||||
public bool AllowConcurrentLogins { get; set; } = StaticAllowConcurrentLogins;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default duration (in milliseconds) of failed login attempts.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// The default duration (in milliseconds) of failed login attempts.
|
||||
/// </value>
|
||||
/// <remarks>
|
||||
/// The user login endpoint ensures that failed login attempts take at least as long as the average successful login.
|
||||
/// However, if no successful logins have occurred, this value is used as the default duration.
|
||||
/// </remarks>
|
||||
[Range(0, int.MaxValue)] // TODO (V17): Change property type to short and update maximum range to short.MaxValue
|
||||
[DefaultValue(StaticUserDefaultFailedLoginDurationInMilliseconds)]
|
||||
public long UserDefaultFailedLoginDurationInMilliseconds { get; set; } = StaticUserDefaultFailedLoginDurationInMilliseconds;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum duration (in milliseconds) of failed login attempts.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// The minimum duration (in milliseconds) of failed login attempts.
|
||||
/// </value>
|
||||
[Range(0, int.MaxValue)] // TODO (V17): Change property type to short and update maximum range to short.MaxValue
|
||||
[DefaultValue(StaticUserMinimumFailedLoginDurationInMilliseconds)]
|
||||
public long UserMinimumFailedLoginDurationInMilliseconds { get; set; } = StaticUserMinimumFailedLoginDurationInMilliseconds;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Umbraco.Cms.Core;
|
||||
|
||||
public static partial class Constants
|
||||
{
|
||||
public static class Cache
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines the string used to represent a null value in the cache.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Used in conjunction with the option to cache null values on the repository caches, so we
|
||||
/// can distinguish a true null "not found" value and a cached null value.</remarks>
|
||||
public const string NullRepresentationInCache = "*NULL*";
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Umbraco.Cms.Core;
|
||||
namespace Umbraco.Cms.Core;
|
||||
|
||||
public static partial class Constants
|
||||
{
|
||||
@@ -24,14 +24,42 @@ public static partial class Constants
|
||||
public static class OutputCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Output cache policy name for content
|
||||
/// Output cache policy name for content.
|
||||
/// </summary>
|
||||
public const string ContentCachePolicy = "DeliveryApiContent";
|
||||
|
||||
/// <summary>
|
||||
/// Output cache policy name for media
|
||||
/// Output cache policy name for media.
|
||||
/// </summary>
|
||||
public const string MediaCachePolicy = "DeliveryApiMedia";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constants for Delivery API header names.
|
||||
/// </summary>
|
||||
public static class HeaderNames
|
||||
{
|
||||
/// <summary>
|
||||
/// Header name for accept language.
|
||||
/// </summary>
|
||||
public const string AcceptLanguage = "Accept-Language";
|
||||
|
||||
/// <summary>
|
||||
/// Header name for API key.
|
||||
/// </summary>
|
||||
public const string ApiKey = "Api-Key";
|
||||
|
||||
/// <summary>
|
||||
/// Header name for preview.
|
||||
/// </summary>
|
||||
public const string Preview = "Preview";
|
||||
|
||||
/// <summary>
|
||||
/// Header name for start item.
|
||||
/// </summary>
|
||||
public const string StartItem = "Start-Item";
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Umbraco.Cms.Core;
|
||||
|
||||
public static partial class Constants
|
||||
{
|
||||
public static class Entities
|
||||
{
|
||||
public static class AdditionalDataKeys
|
||||
{
|
||||
public const string MemberPreviousUserName = "previousUsername";
|
||||
|
||||
public const string MemberGroupPreviousName = "previousName";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,27 @@ public class ApiContentPathResolver : IApiContentPathResolver
|
||||
_apiPublishedContentCache = apiPublishedContentCache;
|
||||
}
|
||||
|
||||
[Obsolete("No longer used in V15. Scheduled for removal in V15.")]
|
||||
public virtual bool IsResolvablePath(string path)
|
||||
{
|
||||
// File requests will blow up with an downstream exception in GetRequiredPublishedSnapshot, which fails due to an UmbracoContext
|
||||
// not being available for what's considered a static file request.
|
||||
// See: https://github.com/umbraco/Umbraco-CMS/issues/19051
|
||||
// Given a URL segment and hence route can't contain a period, we can safely assume that if the last segment of the path contains
|
||||
// a period, it's a file request and should return null here.
|
||||
if (IsFileRequest(path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsFileRequest(string path) => path
|
||||
.Split('/', StringSplitOptions.RemoveEmptyEntries)
|
||||
.LastOrDefault()?
|
||||
.Contains('.') is true;
|
||||
|
||||
public virtual IPublishedContent? ResolveContentPath(string path)
|
||||
{
|
||||
path = path.EnsureStartsWith("/");
|
||||
|
||||
@@ -4,5 +4,8 @@ namespace Umbraco.Cms.Core.DeliveryApi;
|
||||
|
||||
public interface IApiContentPathResolver
|
||||
{
|
||||
[Obsolete("No longer used in V15. Scheduled for removal in V15.")]
|
||||
bool IsResolvablePath(string path) => true;
|
||||
|
||||
IPublishedContent? ResolveContentPath(string path);
|
||||
}
|
||||
|
||||
@@ -8,12 +8,14 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Cache.PartialViewCacheInvalidators;
|
||||
using Umbraco.Cms.Core.Composing;
|
||||
using Umbraco.Cms.Core.Configuration;
|
||||
using Umbraco.Cms.Core.Configuration.Grid;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Diagnostics;
|
||||
using Umbraco.Cms.Core.Dictionary;
|
||||
using Umbraco.Cms.Core.DynamicRoot;
|
||||
using Umbraco.Cms.Core.Editors;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Features;
|
||||
@@ -35,7 +37,6 @@ using Umbraco.Cms.Core.Runtime;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.DynamicRoot;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
using Umbraco.Cms.Core.Telemetry;
|
||||
using Umbraco.Cms.Core.Templates;
|
||||
@@ -341,6 +342,11 @@ namespace Umbraco.Cms.Core.DependencyInjection
|
||||
// Data type configuration cache
|
||||
Services.AddUnique<IDataTypeConfigurationCache, DataTypeConfigurationCache>();
|
||||
Services.AddNotificationHandler<DataTypeCacheRefresherNotification, DataTypeConfigurationCacheRefresher>();
|
||||
|
||||
// Partial view cache invalidators (no-op, shipped implementation is added in Umbraco.Web.Website, but we
|
||||
// need this to ensure we have a service registered for this interface even in headless setups).
|
||||
// See: https://github.com/umbraco/Umbraco-CMS/issues/19661
|
||||
Services.AddUnique<IMemberPartialViewCacheInvalidator, NoopMemberPartialViewCacheInvalidator>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -298,9 +298,6 @@
|
||||
<key alias="removeTextBox">Fjern denne tekstboks</key>
|
||||
<key alias="contentRoot">Indholdsrod</key>
|
||||
<key alias="includeUnpublished">Inkluder ikke-udgivet indhold.</key>
|
||||
<key alias="forceRepublish">Udgiv uændrede elementer.</key>
|
||||
<key alias="forceRepublishWarning">ADVARSEL: Udgivelse af alle sider under denne i indholdstræet, uanset om de er ændret eller ej, kan være en ressourcekrævende og langvarig proces.</key>
|
||||
<key alias="forceRepublishAdvisory">Dette bør ikke være nødvendigt under normale omstændigheder, så fortsæt kun med denne handling, hvis du er sikker på, at det er nødvendigt.</key>
|
||||
<key alias="isSensitiveValue">Denne værdi er skjult. Hvis du har brug for adgang til at se denne værdi, bedes du
|
||||
kontakte din administrator.
|
||||
</key>
|
||||
|
||||
@@ -294,6 +294,7 @@
|
||||
Content.
|
||||
</key>
|
||||
<key alias="nestedContentDeleteAllItems">Are you sure you want to delete all items?</key>
|
||||
<key alias="nestedContentDeleteItem">Are you sure you want to delete this item?</key>
|
||||
<key alias="nestedContentNoContentTypes">No Content Types are configured for this property.</key>
|
||||
<key alias="nestedContentAddElementType">Add Element Type</key>
|
||||
<key alias="nestedContentSelectElementTypeModalTitle">Select Element Type</key>
|
||||
@@ -309,9 +310,6 @@
|
||||
<key alias="removeTextBox">Remove this text box</key>
|
||||
<key alias="contentRoot">Content root</key>
|
||||
<key alias="includeUnpublished">Include unpublished content items.</key>
|
||||
<key alias="forceRepublish">Publish unchanged items.</key>
|
||||
<key alias="forceRepublishWarning">WARNING: Publishing all pages below this one in the content tree, whether or not they have changed, can be an expensive and long-running operation.</key>
|
||||
<key alias="forceRepublishAdvisory">This should not be necessary in normal circumstances so please only proceed with this option selected if you are certain it is required.</key>
|
||||
<key alias="isSensitiveValue">This value is hidden. If you need access to view this value please contact your
|
||||
website administrator.
|
||||
</key>
|
||||
@@ -334,6 +332,7 @@
|
||||
<key alias="schedulePublishHelp">Select the date and time to publish and/or unpublish the content item.</key>
|
||||
<key alias="createEmpty">Create new</key>
|
||||
<key alias="createFromClipboard">Paste from clipboard</key>
|
||||
<key alias="removeItem">Remove item</key>
|
||||
<key alias="nodeIsInTrash">This item is in the Recycle Bin</key>
|
||||
<key alias="noProperties">No content can be added for this item</key>
|
||||
<key alias="variantSaveNotAllowed">Save is not allowed</key>
|
||||
|
||||
@@ -290,6 +290,7 @@
|
||||
<![CDATA[<a href="https://docs.umbraco.com/umbraco-cms/fundamentals/data/scheduled-publishing#timezones" target="_blank" rel="noopener">What does this mean?</a>]]></key>
|
||||
<key alias="nestedContentDeleteItem">Are you sure you want to delete this item?</key>
|
||||
<key alias="nestedContentDeleteAllItems">Are you sure you want to delete all items?</key>
|
||||
<key alias="nestedContentDeleteItem">Are you sure you want to delete this item?</key>
|
||||
<key alias="nestedContentEditorNotSupported">Property %0% uses editor %1% which is not supported by Nested
|
||||
Content.
|
||||
</key>
|
||||
@@ -308,9 +309,6 @@
|
||||
<key alias="removeTextBox">Remove this text box</key>
|
||||
<key alias="contentRoot">Content root</key>
|
||||
<key alias="includeUnpublished">Include unpublished content items.</key>
|
||||
<key alias="forceRepublish">Publish unchanged items.</key>
|
||||
<key alias="forceRepublishWarning">WARNING: Publishing all pages below this one in the content tree, whether or not they have changed, can be an expensive and long-running operation.</key>
|
||||
<key alias="forceRepublishAdvisory">This should not be necessary in normal circumstances so please only proceed with this option selected if you are certain it is required.</key>
|
||||
<key alias="isSensitiveValue">This value is hidden. If you need access to view this value please contact your
|
||||
website administrator.
|
||||
</key>
|
||||
@@ -333,6 +331,7 @@
|
||||
<key alias="schedulePublishHelp">Select the date and time to publish and/or unpublish the content item.</key>
|
||||
<key alias="createEmpty">Create new</key>
|
||||
<key alias="createFromClipboard">Paste from clipboard</key>
|
||||
<key alias="removeItem">Remove item</key>
|
||||
<key alias="nodeIsInTrash">This item is in the Recycle Bin</key>
|
||||
<key alias="variantSaveNotAllowed">Save is not allowed</key>
|
||||
<key alias="variantPublishNotAllowed">Publish is not allowed</key>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace Umbraco.Extensions;
|
||||
|
||||
public static class IntExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Does something 'x' amount of times
|
||||
/// Does something 'x' amount of times.
|
||||
/// </summary>
|
||||
/// <param name="n"></param>
|
||||
/// <param name="action"></param>
|
||||
/// <param name="n">Number of times to execute the action.</param>
|
||||
/// <param name="action">The action to execute.</param>
|
||||
public static void Times(this int n, Action<int> action)
|
||||
{
|
||||
for (var i = 0; i < n; i++)
|
||||
@@ -19,11 +21,11 @@ public static class IntExtensions
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Guid based on an integer value
|
||||
/// Creates a Guid based on an integer value.
|
||||
/// </summary>
|
||||
/// <param name="value"><see cref="int" /> value to convert</param>
|
||||
/// <param name="value">The <see cref="int" /> value to convert.</param>
|
||||
/// <returns>
|
||||
/// <see cref="Guid" />
|
||||
/// The converted <see cref="Guid" />.
|
||||
/// </returns>
|
||||
public static Guid ToGuid(this int value)
|
||||
{
|
||||
@@ -31,4 +33,28 @@ public static class IntExtensions
|
||||
BitConverter.GetBytes(value).CopyTo(bytes, 0);
|
||||
return new Guid(bytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores a GUID previously created from an integer value using <see cref="ToGuid" />.
|
||||
/// </summary>
|
||||
/// <param name="value">The <see cref="Guid" /> value to convert.</param>
|
||||
/// <param name="result">The converted <see cref="int" />.</param>
|
||||
/// <returns>
|
||||
/// True if the <see cref="int" /> value could be created, otherwise false.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// This is used with Umbraco entities that only have integer references in the database (e.g. users).
|
||||
/// </remarks>
|
||||
public static bool TryParseFromGuid(Guid value, [NotNullWhen(true)] out int? result)
|
||||
{
|
||||
if (value.ToString().EndsWith("-0000-0000-0000-000000000000") is false)
|
||||
{
|
||||
// We have a proper GUID, not one converted from an integer.
|
||||
result = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
result = BitConverter.ToInt32(value.ToByteArray());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -826,7 +826,7 @@ public static class PublishedContentExtensions
|
||||
/// </param>
|
||||
/// <returns></returns>
|
||||
/// <remarks>
|
||||
/// This can be useful in order to return all nodes in an entire site by a type when combined with TypedContentAtRoot
|
||||
/// This can be useful in order to return all nodes in an entire site by a type when combined with ContentAtRoot
|
||||
/// </remarks>
|
||||
public static IEnumerable<IPublishedContent> DescendantsOrSelfOfType(
|
||||
this IEnumerable<IPublishedContent> parentNodes, IVariationContextAccessor variationContextAccessor, string docTypeAlias, string? culture = null) => parentNodes.SelectMany(x =>
|
||||
@@ -843,7 +843,7 @@ public static class PublishedContentExtensions
|
||||
/// </param>
|
||||
/// <returns></returns>
|
||||
/// <remarks>
|
||||
/// This can be useful in order to return all nodes in an entire site by a type when combined with TypedContentAtRoot
|
||||
/// This can be useful in order to return all nodes in an entire site by a type when combined with ContentAtRoot
|
||||
/// </remarks>
|
||||
public static IEnumerable<T> DescendantsOrSelf<T>(this IEnumerable<IPublishedContent> parentNodes, IVariationContextAccessor variationContextAccessor, string? culture = null)
|
||||
where T : class, IPublishedContent =>
|
||||
|
||||
@@ -21,16 +21,17 @@ public sealed class PublicAccessHandler :
|
||||
|
||||
private void Handle(IEnumerable<IMemberGroup> affectedEntities)
|
||||
{
|
||||
var keyName = Constants.Entities.AdditionalDataKeys.MemberGroupPreviousName;
|
||||
foreach (IMemberGroup grp in affectedEntities)
|
||||
{
|
||||
// check if the name has changed
|
||||
if ((grp.AdditionalData?.ContainsKey("previousName") ?? false)
|
||||
&& grp.AdditionalData["previousName"] != null
|
||||
&& grp.AdditionalData["previousName"]?.ToString().IsNullOrWhiteSpace() == false
|
||||
&& grp.AdditionalData["previousName"]?.ToString() != grp.Name)
|
||||
if ((grp.AdditionalData?.ContainsKey(keyName) ?? false)
|
||||
&& grp.AdditionalData[keyName] != null
|
||||
&& grp.AdditionalData[keyName]?.ToString().IsNullOrWhiteSpace() == false
|
||||
&& grp.AdditionalData[keyName]?.ToString() != grp.Name)
|
||||
{
|
||||
_publicAccessService.RenameMemberGroupRoleRules(
|
||||
grp.AdditionalData["previousName"]?.ToString(),
|
||||
grp.AdditionalData[keyName]?.ToString(),
|
||||
grp.Name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,19 +346,12 @@ namespace Umbraco.Cms.Core.IO
|
||||
// our root path, due to relative segments, so better check
|
||||
if (_ioHelper.PathStartsWith(path, _rootPath, Path.DirectorySeparatorChar))
|
||||
{
|
||||
// this says that 4.7.2 supports long paths - but Windows does not
|
||||
// https://docs.microsoft.com/en-us/dotnet/api/system.io.pathtoolongexception?view=netframework-4.7.2
|
||||
if (path.Length > 260)
|
||||
{
|
||||
throw new PathTooLongException($"Path {path} is too long.");
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
// nothing prevents us to reach the file, security-wise, yet it is outside
|
||||
// this filesystem's root - throw
|
||||
throw new UnauthorizedAccessException($"File original: [{originalPath}] full: [{path}] is outside this filesystem's root.");
|
||||
throw new UnauthorizedAccessException($"Requested path {originalPath} is outside this filesystem's root.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -59,43 +59,11 @@ public enum ContentSaveAction
|
||||
/// Saves and publishes the content item including all descendants regardless of whether they have a published version
|
||||
/// or not.
|
||||
/// </summary>
|
||||
[Obsolete("This option is no longer used as the 'force' aspect has been extended into options for publishing unpublished and re-publishing changed content. Please use one of those options instead.")]
|
||||
PublishWithDescendantsForce = 10,
|
||||
|
||||
/// <summary>
|
||||
/// Creates and publishes the new content item including all descendants regardless of whether they have a published
|
||||
/// version or not.
|
||||
/// </summary>
|
||||
[Obsolete("This option is no longer used as the 'force' aspect has been extended into options for publishing unpublished and re-publishing changed content. Please use one of those options instead.")]
|
||||
PublishWithDescendantsForceNew = 11,
|
||||
|
||||
/// <summary>
|
||||
/// Saves and publishes the content item including all descendants including publishing previously unpublished content.
|
||||
/// </summary>
|
||||
PublishWithDescendantsIncludeUnpublished = 12,
|
||||
|
||||
/// <summary>
|
||||
/// Saves and publishes the new content item including all descendants including publishing previously unpublished content.
|
||||
/// </summary>
|
||||
PublishWithDescendantsIncludeUnpublishedNew = 13,
|
||||
|
||||
/// <summary>
|
||||
/// Saves and publishes the content item including all descendants irrespective of whether there are any pending changes.
|
||||
/// </summary>
|
||||
PublishWithDescendantsForceRepublish = 14,
|
||||
|
||||
/// <summary>
|
||||
/// Saves and publishes the new content item including all descendants including publishing previously unpublished content.
|
||||
/// </summary>
|
||||
PublishWithDescendantsForceRepublishNew = 15,
|
||||
|
||||
/// <summary>
|
||||
/// Saves and publishes the content item including all descendants including publishing previously unpublished content and irrespective of whether there are any pending changes.
|
||||
/// </summary>
|
||||
PublishWithDescendantsIncludeUnpublishedAndForceRepublish = 16,
|
||||
|
||||
/// <summary>
|
||||
/// Saves and publishes the new content item including all descendants including publishing previously unpublished content and irrespective of whether there are any pending changes.
|
||||
/// </summary>
|
||||
PublishWithDescendantsIncludeUnpublishedAndForceRepublishNew = 17,
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ public class PropertyTypeBasic
|
||||
|
||||
[Required]
|
||||
[RegularExpression(@"^([a-zA-Z]\w.*)$", ErrorMessage = "Invalid alias")]
|
||||
[MaxLength(255, ErrorMessage = "Alias is too long")]
|
||||
[DataMember(Name = "alias")]
|
||||
public string Alias { get; set; } = null!;
|
||||
|
||||
|
||||
@@ -132,6 +132,30 @@ public abstract class ContentTypeBase : TreeEntityBase, IContentTypeBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects if any persisted property types have been removed by comparing old and new collections,
|
||||
/// and sets <see cref="HasPropertyTypeBeenRemoved"/> accordingly.
|
||||
/// </summary>
|
||||
private void DetectPropertyTypeRemovals(IEnumerable<IPropertyType> oldPropertyTypes, IEnumerable<IPropertyType> newPropertyTypes)
|
||||
{
|
||||
if (HasPropertyTypeBeenRemoved)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var oldIds = new HashSet<int>(oldPropertyTypes.Select(pt => pt.Id).Where(id => id > 0));
|
||||
if (oldIds.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var newIds = new HashSet<int>(newPropertyTypes.Select(pt => pt.Id).Where(id => id > 0));
|
||||
if (oldIds.Any(id => !newIds.Contains(id)))
|
||||
{
|
||||
HasPropertyTypeBeenRemoved = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PropertyTypes that are not part of a PropertyGroup
|
||||
/// </summary>
|
||||
@@ -273,6 +297,7 @@ public abstract class ContentTypeBase : TreeEntityBase, IContentTypeBase
|
||||
{
|
||||
if (PropertyTypeCollection != null)
|
||||
{
|
||||
DetectPropertyTypeRemovals(PropertyTypeCollection, value);
|
||||
PropertyTypeCollection.ClearCollectionChangedEvents();
|
||||
}
|
||||
|
||||
@@ -443,21 +468,21 @@ public abstract class ContentTypeBase : TreeEntityBase, IContentTypeBase
|
||||
/// Please note that resetting the dirty properties could potentially
|
||||
/// obstruct the saving of a new or updated entity.
|
||||
/// </remarks>
|
||||
public override void ResetDirtyProperties()
|
||||
public override void ResetDirtyProperties(bool rememberDirty)
|
||||
{
|
||||
base.ResetDirtyProperties();
|
||||
base.ResetDirtyProperties(rememberDirty);
|
||||
|
||||
// loop through each property group to reset the property types
|
||||
var propertiesReset = new List<int>();
|
||||
|
||||
foreach (PropertyGroup propertyGroup in PropertyGroups)
|
||||
{
|
||||
propertyGroup.ResetDirtyProperties();
|
||||
propertyGroup.ResetDirtyProperties(rememberDirty);
|
||||
if (propertyGroup.PropertyTypes is not null)
|
||||
{
|
||||
foreach (IPropertyType propertyType in propertyGroup.PropertyTypes)
|
||||
{
|
||||
propertyType.ResetDirtyProperties();
|
||||
propertyType.ResetDirtyProperties(rememberDirty);
|
||||
propertiesReset.Add(propertyType.Id);
|
||||
}
|
||||
}
|
||||
@@ -467,7 +492,7 @@ public abstract class ContentTypeBase : TreeEntityBase, IContentTypeBase
|
||||
// but don't re-reset ones we've already done.
|
||||
foreach (IPropertyType propertyType in PropertyTypes.Where(x => propertiesReset.Contains(x.Id) == false))
|
||||
{
|
||||
propertyType.ResetDirtyProperties();
|
||||
propertyType.ResetDirtyProperties(rememberDirty);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ public abstract class ContentTypeCompositionBase : ContentTypeBase, IContentType
|
||||
{
|
||||
private List<IContentTypeComposition> _contentTypeComposition = new();
|
||||
private List<int> _removedContentTypeKeyTracker = new();
|
||||
private bool _hasCompositionBeenRemoved;
|
||||
|
||||
protected ContentTypeCompositionBase(IShortStringHelper shortStringHelper, int parentId)
|
||||
: base(shortStringHelper, parentId)
|
||||
@@ -104,6 +105,24 @@ public abstract class ContentTypeCompositionBase : ContentTypeBase, IContentType
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A boolean flag indicating if a composition has been removed from this instance.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is currently (specifically) used in order to know that we need to refresh the content cache which
|
||||
/// needs to occur when a composition has been removed from a content type
|
||||
/// </remarks>
|
||||
[IgnoreDataMember]
|
||||
internal bool HasCompositionTypeBeenRemoved
|
||||
{
|
||||
get => _hasCompositionBeenRemoved;
|
||||
private set
|
||||
{
|
||||
_hasCompositionBeenRemoved = value;
|
||||
OnPropertyChanged(nameof(HasCompositionTypeBeenRemoved));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<IPropertyType> GetOriginalComposedPropertyTypes() => GetRawComposedPropertyTypes();
|
||||
|
||||
@@ -179,6 +198,8 @@ public abstract class ContentTypeCompositionBase : ContentTypeBase, IContentType
|
||||
_removedContentTypeKeyTracker.AddRange(compositionIdsToRemove);
|
||||
}
|
||||
|
||||
HasCompositionTypeBeenRemoved = true;
|
||||
|
||||
OnPropertyChanged(nameof(ContentTypeComposition));
|
||||
|
||||
return _contentTypeComposition.Remove(contentTypeComposition);
|
||||
|
||||
@@ -10,5 +10,7 @@ public sealed class ApiContentRoute : IApiContentRoute
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public string? QueryString { get; set; }
|
||||
|
||||
public IApiContentStartItem StartItem { get; }
|
||||
}
|
||||
|
||||
@@ -4,5 +4,10 @@ public interface IApiContentRoute
|
||||
{
|
||||
string Path { get; }
|
||||
|
||||
public string? QueryString
|
||||
{
|
||||
get => null; set { }
|
||||
}
|
||||
|
||||
IApiContentStartItem StartItem { get; }
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ _additionalData ??= new Dictionary<string, object?>();
|
||||
// if the name has changed, add the value to the additional data,
|
||||
// this is required purely for event handlers to know the previous name of the group
|
||||
// so we can keep the public access up to date.
|
||||
AdditionalData["previousName"] = _name;
|
||||
AdditionalData[Constants.Entities.AdditionalDataKeys.MemberGroupPreviousName] = _name;
|
||||
}
|
||||
|
||||
SetPropertyValueAndDetectChanges(value, ref _name, nameof(Name));
|
||||
|
||||
@@ -20,6 +20,7 @@ public class PropertyGroup : EntityBase, IEquatable<PropertyGroup>
|
||||
|
||||
private string _alias;
|
||||
private string? _name;
|
||||
private bool _hasPropertyTypeBeenRemoved;
|
||||
private PropertyTypeCollection? _propertyTypes;
|
||||
private int _sortOrder;
|
||||
|
||||
@@ -94,6 +95,20 @@ public class PropertyGroup : EntityBase, IEquatable<PropertyGroup>
|
||||
set => SetPropertyValueAndDetectChanges(value, ref _sortOrder, nameof(SortOrder));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A boolean flag indicating if a property type has been removed from this group.
|
||||
/// </summary>
|
||||
[IgnoreDataMember]
|
||||
internal bool HasPropertyTypeBeenRemoved
|
||||
{
|
||||
get => _hasPropertyTypeBeenRemoved;
|
||||
private set
|
||||
{
|
||||
_hasPropertyTypeBeenRemoved = value;
|
||||
OnPropertyChanged(nameof(HasPropertyTypeBeenRemoved));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a collection of property types for the group.
|
||||
/// </summary>
|
||||
@@ -112,6 +127,7 @@ public class PropertyGroup : EntityBase, IEquatable<PropertyGroup>
|
||||
{
|
||||
if (_propertyTypes != null)
|
||||
{
|
||||
DetectPropertyTypeRemovals(_propertyTypes, value);
|
||||
_propertyTypes.ClearCollectionChangedEvents();
|
||||
}
|
||||
|
||||
@@ -155,4 +171,27 @@ public class PropertyGroup : EntityBase, IEquatable<PropertyGroup>
|
||||
|
||||
private void PropertyTypesChanged(object? sender, NotifyCollectionChangedEventArgs e) =>
|
||||
OnPropertyChanged(nameof(PropertyTypes));
|
||||
|
||||
private void DetectPropertyTypeRemovals(PropertyTypeCollection oldPropertyTypes, PropertyTypeCollection? newPropertyTypes)
|
||||
{
|
||||
if (HasPropertyTypeBeenRemoved)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var oldIds = new HashSet<int>(oldPropertyTypes.Select(pt => pt.Id).Where(id => id > 0));
|
||||
if (oldIds.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var newIds = newPropertyTypes != null
|
||||
? new HashSet<int>(newPropertyTypes.Select(pt => pt.Id).Where(id => id > 0))
|
||||
: new HashSet<int>();
|
||||
|
||||
if (oldIds.Any(id => !newIds.Contains(id)))
|
||||
{
|
||||
HasPropertyTypeBeenRemoved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ public class PublishedValueFallback : IPublishedValueFallback
|
||||
/// <inheritdoc />
|
||||
public bool TryGetValue<T>(IPublishedProperty property, string? culture, string? segment, Fallback fallback, T? defaultValue, out T? value)
|
||||
{
|
||||
_variationContextAccessor.ContextualizeVariation(property.PropertyType.Variations, ref culture, ref segment);
|
||||
_variationContextAccessor.ContextualizeVariation(property.PropertyType.Variations, property.Alias, ref culture, ref segment);
|
||||
|
||||
foreach (var f in fallback)
|
||||
{
|
||||
@@ -78,7 +78,7 @@ public class PublishedValueFallback : IPublishedValueFallback
|
||||
return false;
|
||||
}
|
||||
|
||||
_variationContextAccessor.ContextualizeVariation(propertyType.Variations, ref culture, ref segment);
|
||||
_variationContextAccessor.ContextualizeVariation(propertyType.Variations, alias, ref culture, ref segment);
|
||||
|
||||
foreach (var f in fallback)
|
||||
{
|
||||
@@ -124,7 +124,7 @@ public class PublishedValueFallback : IPublishedValueFallback
|
||||
IPublishedPropertyType? propertyType = content.ContentType.GetPropertyType(alias);
|
||||
if (propertyType != null)
|
||||
{
|
||||
_variationContextAccessor.ContextualizeVariation(propertyType.Variations, content.Id, ref culture, ref segment);
|
||||
_variationContextAccessor.ContextualizeVariation(propertyType.Variations, content.Id, alias, ref culture, ref segment);
|
||||
noValueProperty = content.GetProperty(alias);
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ public class PublishedValueFallback : IPublishedValueFallback
|
||||
{
|
||||
culture = null;
|
||||
segment = null;
|
||||
_variationContextAccessor.ContextualizeVariation(propertyType.Variations, content.Id, ref culture, ref segment);
|
||||
_variationContextAccessor.ContextualizeVariation(propertyType.Variations, content.Id, alias, ref culture, ref segment);
|
||||
}
|
||||
|
||||
property = content?.GetProperty(alias);
|
||||
|
||||
@@ -25,9 +25,15 @@ public class VariationContext
|
||||
public string Segment { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the segment for the content item
|
||||
/// Gets the segment for the content item.
|
||||
/// </summary>
|
||||
/// <param name="contentId"></param>
|
||||
/// <returns></returns>
|
||||
/// <param name="contentId">The content Id.</param>
|
||||
public virtual string GetSegment(int contentId) => Segment;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the segment for the content item and property alias.
|
||||
/// </summary>
|
||||
/// <param name="contentId">The content Id.</param>
|
||||
/// <param name="propertyAlias">The property alias.</param>
|
||||
public virtual string GetSegment(int contentId, string propertyAlias) => Segment;
|
||||
}
|
||||
|
||||
@@ -8,25 +8,45 @@ namespace Umbraco.Extensions;
|
||||
|
||||
public static class VariationContextAccessorExtensions
|
||||
{
|
||||
[Obsolete("Please use the method overload that accepts all parameters. Scheduled for removal in Umbraco 18.")]
|
||||
public static void ContextualizeVariation(
|
||||
this IVariationContextAccessor variationContextAccessor,
|
||||
ContentVariation variations,
|
||||
ref string? culture,
|
||||
ref string? segment)
|
||||
=> variationContextAccessor.ContextualizeVariation(variations, null, ref culture, ref segment);
|
||||
=> variationContextAccessor.ContextualizeVariation(variations, null, null, ref culture, ref segment);
|
||||
|
||||
public static void ContextualizeVariation(
|
||||
this IVariationContextAccessor variationContextAccessor,
|
||||
ContentVariation variations,
|
||||
string? propertyAlias,
|
||||
ref string? culture,
|
||||
ref string? segment)
|
||||
=> variationContextAccessor.ContextualizeVariation(variations, null, propertyAlias, ref culture, ref segment);
|
||||
|
||||
[Obsolete("Please use the method overload that accepts all parameters. Scheduled for removal in Umbraco 18.")]
|
||||
public static void ContextualizeVariation(
|
||||
this IVariationContextAccessor variationContextAccessor,
|
||||
ContentVariation variations,
|
||||
int contentId,
|
||||
ref string? culture,
|
||||
ref string? segment)
|
||||
=> variationContextAccessor.ContextualizeVariation(variations, (int?)contentId, ref culture, ref segment);
|
||||
=> variationContextAccessor.ContextualizeVariation(variations, (int?)contentId, null, ref culture, ref segment);
|
||||
|
||||
public static void ContextualizeVariation(
|
||||
this IVariationContextAccessor variationContextAccessor,
|
||||
ContentVariation variations,
|
||||
int contentId,
|
||||
string? propertyAlias,
|
||||
ref string? culture,
|
||||
ref string? segment)
|
||||
=> variationContextAccessor.ContextualizeVariation(variations, (int?)contentId, propertyAlias, ref culture, ref segment);
|
||||
|
||||
private static void ContextualizeVariation(
|
||||
this IVariationContextAccessor variationContextAccessor,
|
||||
ContentVariation variations,
|
||||
int? contentId,
|
||||
string? propertyAlias,
|
||||
ref string? culture,
|
||||
ref string? segment)
|
||||
{
|
||||
@@ -37,18 +57,22 @@ public static class VariationContextAccessorExtensions
|
||||
|
||||
// use context values
|
||||
VariationContext? publishedVariationContext = variationContextAccessor?.VariationContext;
|
||||
if (culture == null)
|
||||
{
|
||||
culture = variations.VariesByCulture() ? publishedVariationContext?.Culture : string.Empty;
|
||||
}
|
||||
culture ??= variations.VariesByCulture() ? publishedVariationContext?.Culture : string.Empty;
|
||||
|
||||
if (segment == null)
|
||||
{
|
||||
if (variations.VariesBySegment())
|
||||
{
|
||||
segment = contentId == null
|
||||
? publishedVariationContext?.Segment
|
||||
: publishedVariationContext?.GetSegment(contentId.Value);
|
||||
if (contentId == null)
|
||||
{
|
||||
segment = publishedVariationContext?.Segment;
|
||||
}
|
||||
else
|
||||
{
|
||||
segment = propertyAlias == null ?
|
||||
publishedVariationContext?.GetSegment(contentId.Value) :
|
||||
publishedVariationContext?.GetSegment(contentId.Value, propertyAlias);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -3,23 +3,29 @@ using Umbraco.Cms.Core.Security;
|
||||
namespace Umbraco.Cms.Core.Persistence.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for external logins with Guid as key, so it can be shared for members and users
|
||||
/// Repository for external logins with Guid as key, so it can be shared for members and users.
|
||||
/// </summary>
|
||||
public interface IExternalLoginWithKeyRepository : IReadWriteQueryRepository<int, IIdentityUserLogin>,
|
||||
IQueryRepository<IIdentityUserToken>
|
||||
{
|
||||
/// <summary>
|
||||
/// Replaces all external login providers for the user/member key
|
||||
/// Replaces all external login providers for the user/member key.
|
||||
/// </summary>
|
||||
void Save(Guid userOrMemberKey, IEnumerable<IExternalLogin> logins);
|
||||
|
||||
/// <summary>
|
||||
/// Replaces all external login provider tokens for the providers specified for the user/member key
|
||||
/// Replaces all external login provider tokens for the providers specified for the user/member key.
|
||||
/// </summary>
|
||||
void Save(Guid userOrMemberKey, IEnumerable<IExternalLoginToken> tokens);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all external logins for the specified the user/member key
|
||||
/// Deletes all external logins for the specified the user/member key.
|
||||
/// </summary>
|
||||
void DeleteUserLogins(Guid userOrMemberKey);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes external logins that aren't associated with the current collection of providers.
|
||||
/// </summary>
|
||||
/// <param name="currentLoginProviders">The names of the currently configured providers.</param>
|
||||
void DeleteUserLoginsForRemovedProviders(IEnumerable<string> currentLoginProviders) { }
|
||||
}
|
||||
|
||||
@@ -38,4 +38,11 @@ public interface IMemberRepository : IContentRepository<int, IMember>
|
||||
/// <param name="query"></param>
|
||||
/// <returns></returns>
|
||||
int GetCountByQuery(IQuery<IMember>? query);
|
||||
|
||||
/// <summary>
|
||||
/// Saves only the properties related to login for the member, using an optimized, non-locking update.
|
||||
/// </summary>
|
||||
/// <param name="member">The member to update.</param>
|
||||
/// <returns>Used to avoid the full save of the member object after a login operation.</returns>
|
||||
Task UpdateLoginPropertiesAsync(IMember member) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Linq.Expressions;
|
||||
using System.Linq.Expressions;
|
||||
using Umbraco.Cms.Core.Models.Membership;
|
||||
using Umbraco.Cms.Core.Persistence.Querying;
|
||||
|
||||
@@ -109,5 +109,26 @@ public interface IUserRepository : IReadWriteQueryRepository<int, IUser>
|
||||
|
||||
void ClearLoginSession(Guid sessionId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a page of users, ordered by Id and starting from the provided Id.
|
||||
/// </summary>
|
||||
/// <param name="id">The user Id to start retrieving users from.</param>
|
||||
/// <param name="count">The number of users to return.</param>
|
||||
/// <returns>A page of <see cref="IUser"/> instances.</returns>
|
||||
[Obsolete("No longer used in Umbraco. Scheduled for removal in Umbraco 18.")]
|
||||
IEnumerable<IUser> GetNextUsers(int id, int count);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a page of approved users, ordered by Id and starting from the provided Id.
|
||||
/// </summary>
|
||||
/// <param name="id">The user Id to start retrieving users from.</param>
|
||||
/// <param name="count">The number of users to return.</param>
|
||||
/// <returns>A page of <see cref="IUser"/> instances.</returns>
|
||||
IEnumerable<IUser> GetNextApprovedUsers(int id, int count) => Enumerable.Empty<IUser>();
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates sessions for users that aren't associated with the current collection of providers.
|
||||
/// </summary>
|
||||
/// <param name="currentProviderKeys">The keys for the currently configured providers.</param>
|
||||
void InvalidateSessionsForRemovedProviders(IEnumerable<string> currentProviderKeys) { }
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@ public interface IPublishedSnapshotService : IDisposable
|
||||
/// </remarks>
|
||||
IPublishedSnapshot CreatePublishedSnapshot(string? previewToken);
|
||||
|
||||
/// <summary>
|
||||
/// Indicates if the database cache is in the process of being rebuilt.
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
bool IsRebuilding() => false;
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds internal database caches (but does not reload).
|
||||
/// </summary>
|
||||
@@ -61,6 +67,38 @@ public interface IPublishedSnapshotService : IDisposable
|
||||
IReadOnlyCollection<int>? mediaTypeIds = null,
|
||||
IReadOnlyCollection<int>? memberTypeIds = null);
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds internal database caches (but does not reload).
|
||||
/// </summary>
|
||||
/// <param name="contentTypeIds">
|
||||
/// If not null will process content for the matching content types, if empty will process all
|
||||
/// content
|
||||
/// </param>
|
||||
/// <param name="mediaTypeIds">
|
||||
/// If not null will process content for the matching media types, if empty will process all
|
||||
/// media
|
||||
/// </param>
|
||||
/// <param name="memberTypeIds">
|
||||
/// If not null will process content for the matching members types, if empty will process all
|
||||
/// members
|
||||
/// </param>
|
||||
/// <param name="useBackgroundThread">Flag indicating whether to use a background thread for the operation and immediately return to the caller.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Forces the snapshot service to rebuild its internal database caches. For instance, some caches
|
||||
/// may rely on a database table to store pre-serialized version of documents.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This does *not* reload the caches. Caches need to be reloaded, for instance via
|
||||
/// <see cref="DistributedCache" /> RefreshAllPublishedSnapshot method.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
void Rebuild(
|
||||
bool useBackgroundThread,
|
||||
IReadOnlyCollection<int>? contentTypeIds = null,
|
||||
IReadOnlyCollection<int>? mediaTypeIds = null,
|
||||
IReadOnlyCollection<int>? memberTypeIds = null) => Rebuild(contentTypeIds, mediaTypeIds, memberTypeIds);
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds all internal database caches (but does not reload).
|
||||
@@ -77,6 +115,22 @@ public interface IPublishedSnapshotService : IDisposable
|
||||
/// </remarks>
|
||||
void RebuildAll() => Rebuild(Array.Empty<int>(), Array.Empty<int>(), Array.Empty<int>());
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds all internal database caches (but does not reload).
|
||||
/// </summary>
|
||||
/// <param name="useBackgroundThread">Flag indicating whether to use a background thread for the operation and immediately return to the caller.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Forces the snapshot service to rebuild its internal database caches. For instance, some caches
|
||||
/// may rely on a database table to store pre-serialized version of documents.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This does *not* reload the caches. Caches need to be reloaded, for instance via
|
||||
/// <see cref="DistributedCache" /> RefreshAllPublishedSnapshot method.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
void RebuildAll(bool useBackgroundThread) => Rebuild(useBackgroundThread, Array.Empty<int>(), Array.Empty<int>(), Array.Empty<int>());
|
||||
|
||||
/* An IPublishedCachesService implementation can rely on transaction-level events to update
|
||||
* its internal, database-level data, as these events are purely internal. However, it cannot
|
||||
* rely on cache refreshers CacheUpdated events to update itself, as these events are external
|
||||
|
||||
@@ -250,6 +250,8 @@ public class CoreScope : ICoreScope
|
||||
_parentScope = coreScope;
|
||||
}
|
||||
|
||||
protected bool HasParentScope => _parentScope is not null;
|
||||
|
||||
protected void HandleScopedNotifications() => _notificationPublisher?.ScopeExit(Completed.HasValue && Completed.Value);
|
||||
|
||||
private void EnsureNotDisposed()
|
||||
|
||||
@@ -18,4 +18,20 @@ public static class ContentTypeChangeExtensions
|
||||
|
||||
public static bool HasTypesNone(this ContentTypeChangeTypes change, ContentTypeChangeTypes types) =>
|
||||
(change & types) == ContentTypeChangeTypes.None;
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the change has structural change impact.
|
||||
/// </summary>
|
||||
/// <param name="change">The change to check.</param>
|
||||
/// <returns><c>true</c> if the change has structural impact; otherwise, <c>false</c>.</returns>
|
||||
public static bool IsStructuralChange(this ContentTypeChangeTypes change) =>
|
||||
change.HasType(ContentTypeChangeTypes.RefreshMain);
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the change has non-structural change impact.
|
||||
/// </summary>
|
||||
/// <param name="change">The change to check.</param>
|
||||
/// <returns><c>true</c> if the change has non-structural impact; otherwise, <c>false</c>.</returns>
|
||||
public static bool IsNonStructuralChange(this ContentTypeChangeTypes change) =>
|
||||
change.HasType(ContentTypeChangeTypes.RefreshOther) && !change.HasType(ContentTypeChangeTypes.RefreshMain);
|
||||
}
|
||||
|
||||
@@ -11,14 +11,29 @@ public enum ContentTypeChangeTypes : byte
|
||||
Create = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Content type changes impact only the Content type being saved
|
||||
/// Content type changes directly impact existing content of this content type.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These changes are "destructive" of nature. They include:
|
||||
/// - Changing the content type alias.
|
||||
/// - Removing a property type or a composition.
|
||||
/// - Changing the alias of a property type (this effectively corresponds to removing a property type).
|
||||
/// - Changing variance, either at property or content type level.
|
||||
/// </remarks>
|
||||
RefreshMain = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Content type changes impacts the content type being saved and others used that are composed of it
|
||||
/// Content type changes that do not directly impact existing content of this content type.
|
||||
/// </summary>
|
||||
RefreshOther = 4, // changed, other change
|
||||
/// <remarks>
|
||||
/// These changes are "constructive" of nature, and include all changes not included in
|
||||
/// <see cref="RefreshMain"/> - for example:
|
||||
/// - Adding a property type or a composition.
|
||||
/// - Rearranging property types or groups.
|
||||
/// - Changes to name, description, icon etc.
|
||||
/// - Changes to other content type settings, i.e. allowed child types and version cleanup.
|
||||
/// </remarks>
|
||||
RefreshOther = 4,
|
||||
|
||||
/// <summary>
|
||||
/// Content type was removed
|
||||
|
||||
@@ -2199,7 +2199,7 @@ public class ContentService : RepositoryService, IContentService
|
||||
variesByCulture ? culturesPublished.IsCollectionEmpty() ? null : culturesPublished : ["*"],
|
||||
null,
|
||||
eventMessages));
|
||||
scope.Notifications.Publish(new ContentPublishedNotification(publishedDocuments, eventMessages).WithState(notificationState));
|
||||
scope.Notifications.Publish(new ContentPublishedNotification(publishedDocuments, eventMessages, true).WithState(notificationState));
|
||||
|
||||
scope.Complete();
|
||||
}
|
||||
@@ -3627,6 +3627,7 @@ public class ContentService : RepositoryService, IContentService
|
||||
|
||||
private static readonly string?[] ArrayOfOneNullString = { null };
|
||||
|
||||
/// <inheritdoc />
|
||||
public IContent CreateContentFromBlueprint(IContent blueprint, string name, int userId = Constants.Security.SuperUserId)
|
||||
{
|
||||
if (blueprint == null)
|
||||
|
||||
@@ -226,7 +226,10 @@ public abstract class ContentTypeServiceBase<TRepository, TItem> : ContentTypeSe
|
||||
});
|
||||
|
||||
// removed properties?
|
||||
var hasAnyPropertyBeenRemoved = dirty.WasPropertyDirty("HasPropertyTypeBeenRemoved");
|
||||
// check both the content type level flag (set by RemovePropertyType) and
|
||||
// individual property group flags (set when PropertyTypes collection is replaced, e.g. by the mapper)
|
||||
var hasAnyPropertyBeenRemoved = dirty.WasPropertyDirty("HasPropertyTypeBeenRemoved")
|
||||
|| contentType.PropertyGroups.Any(g => g.WasPropertyDirty("HasPropertyTypeBeenRemoved"));
|
||||
|
||||
// removed compositions?
|
||||
var hasAnyCompositionBeenRemoved = dirty.WasPropertyDirty("HasCompositionTypeBeenRemoved");
|
||||
|
||||
@@ -108,7 +108,7 @@ public class DomainService : RepositoryService, IDomainService
|
||||
EventMessages eventMessages = EventMessagesFactory.Get();
|
||||
|
||||
IDomain[] domains = items.ToArray();
|
||||
if (domains.Length == 0)
|
||||
if (domains.Length == 0 || AreDomainsAlreadySorted(domains))
|
||||
{
|
||||
return OperationResult.Attempt.NoOperation(eventMessages);
|
||||
}
|
||||
@@ -144,4 +144,18 @@ public class DomainService : RepositoryService, IDomainService
|
||||
|
||||
return OperationResult.Attempt.Succeed(eventMessages);
|
||||
}
|
||||
|
||||
private static bool AreDomainsAlreadySorted(IDomain[] domains)
|
||||
{
|
||||
// Check if the domains are already sorted by comparing the current sort order with what we'll set to be the new sort order.
|
||||
for (int i = 0; i < domains.Length; i++)
|
||||
{
|
||||
if (domains[i].SortOrder != i)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,4 +80,14 @@ public class ExternalLoginService : RepositoryService, IExternalLoginWithKeyServ
|
||||
scope.Complete();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void DeleteUserLoginsForRemovedProviders(IEnumerable<string> currentLoginProviders)
|
||||
{
|
||||
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
|
||||
{
|
||||
_externalLoginRepository.DeleteUserLoginsForRemovedProviders(currentLoginProviders);
|
||||
scope.Complete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ public interface IContentService : IContentServiceBase<IContent>
|
||||
/// <summary>
|
||||
/// Creates a new content item from a blueprint.
|
||||
/// </summary>
|
||||
/// <remarks>Warning: If you intend to save the resulting <c>IContent</c> as a content node, you must trigger a
|
||||
/// <see cref="Notifications.ContentScaffoldedNotification"/> notification to ensure that the block ids are regenerated.
|
||||
/// Failing to do so could lead to caching issues.</remarks>
|
||||
IContent CreateContentFromBlueprint(IContent blueprint, string name, int userId = Constants.Security.SuperUserId);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -5,47 +5,53 @@ namespace Umbraco.Cms.Core.Services;
|
||||
public interface IExternalLoginWithKeyService : IService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns all user logins assigned
|
||||
/// Returns all user logins assigned.
|
||||
/// </summary>
|
||||
IEnumerable<IIdentityUserLogin> GetExternalLogins(Guid userOrMemberKey);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all user login tokens assigned
|
||||
/// Returns all user login tokens assigned.
|
||||
/// </summary>
|
||||
IEnumerable<IIdentityUserToken> GetExternalLoginTokens(Guid userOrMemberKey);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all logins matching the login info - generally there should only be one but in some cases
|
||||
/// there might be more than one depending on if an administrator has been editing/removing members
|
||||
/// there might be more than one depending on if an administrator has been editing/removing members.
|
||||
/// </summary>
|
||||
IEnumerable<IIdentityUserLogin> Find(string loginProvider, string providerKey);
|
||||
|
||||
/// <summary>
|
||||
/// Saves the external logins associated with the user
|
||||
/// Saves the external logins associated with the user.
|
||||
/// </summary>
|
||||
/// <param name="userOrMemberKey">
|
||||
/// The user or member key associated with the logins
|
||||
/// The user or member key associated with the logins.
|
||||
/// </param>
|
||||
/// <param name="logins"></param>
|
||||
/// <remarks>
|
||||
/// This will replace all external login provider information for the user
|
||||
/// This will replace all external login provider information for the user.
|
||||
/// </remarks>
|
||||
void Save(Guid userOrMemberKey, IEnumerable<IExternalLogin> logins);
|
||||
|
||||
/// <summary>
|
||||
/// Saves the external login tokens associated with the user
|
||||
/// Saves the external login tokens associated with the user.
|
||||
/// </summary>
|
||||
/// <param name="userOrMemberKey">
|
||||
/// The user or member key associated with the logins
|
||||
/// The user or member key associated with the logins.
|
||||
/// </param>
|
||||
/// <param name="tokens"></param>
|
||||
/// <remarks>
|
||||
/// This will replace all external login tokens for the user
|
||||
/// This will replace all external login tokens for the user.
|
||||
/// </remarks>
|
||||
void Save(Guid userOrMemberKey, IEnumerable<IExternalLoginToken> tokens);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all user logins - normally used when a member is deleted
|
||||
/// Deletes all user logins - normally used when a member is deleted.
|
||||
/// </summary>
|
||||
void DeleteUserLogins(Guid userOrMemberKey);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes external logins that aren't associated with the current collection of providers.
|
||||
/// </summary>
|
||||
/// <param name="currentLoginProviders">The names of the currently configured providers.</param>
|
||||
void DeleteUserLoginsForRemovedProviders(IEnumerable<string> currentLoginProviders) { }
|
||||
}
|
||||
|
||||
@@ -343,4 +343,11 @@ public interface IMemberService : IMembershipMemberService
|
||||
/// <see cref="IEnumerable{IMember}" />
|
||||
/// </returns>
|
||||
IEnumerable<IMember>? GetMembersByPropertyValue(string propertyTypeAlias, DateTime value, ValuePropertyMatchType matchType = ValuePropertyMatchType.Exact);
|
||||
|
||||
/// <summary>
|
||||
/// Saves only the properties related to login for the member, using an optimized, non-locking update.
|
||||
/// </summary>
|
||||
/// <param name="member">The member to update.</param>
|
||||
/// <returns>Used to avoid the full save of the member object after a login operation.</returns>
|
||||
Task UpdateLoginPropertiesAsync(IMember member) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -231,8 +231,29 @@ public interface IUserService : IMembershipUserService
|
||||
/// </returns>
|
||||
IEnumerable<IUser> GetAllNotInGroup(int groupId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a page of users, ordered by Id and starting from the provided Id.
|
||||
/// </summary>
|
||||
/// <param name="id">The user Id to start retrieving users from.</param>
|
||||
/// <param name="count">The number of users to return.</param>
|
||||
/// <returns>A page of <see cref="IUser"/> instances.</returns>
|
||||
[Obsolete("No longer used in Umbraco. Scheduled for removal in Umbraco 18.")]
|
||||
IEnumerable<IUser> GetNextUsers(int id, int count);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a page of approved users, ordered by Id and starting from the provided Id.
|
||||
/// </summary>
|
||||
/// <param name="id">The user Id to start retrieving users from.</param>
|
||||
/// <param name="count">The number of users to return.</param>
|
||||
/// <returns>A page of <see cref="IUser"/> instances.</returns>
|
||||
IEnumerable<IUser> GetNextApprovedUsers(int id, int count) => Enumerable.Empty<IUser>();
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates sessions for users that aren't associated with the current collection of providers.
|
||||
/// </summary>
|
||||
/// <param name="currentLoginProviders">The keys for the currently configured providers.</param>
|
||||
void InvalidateSessionsForRemovedProviders(IEnumerable<string> currentLoginProviders) { }
|
||||
|
||||
#region User groups
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -418,7 +418,7 @@ namespace Umbraco.Cms.Core.Services
|
||||
}
|
||||
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
|
||||
scope.ReadLock(Constants.Locks.ContentTree);
|
||||
scope.ReadLock(Constants.Locks.MediaTree);
|
||||
return _mediaRepository.GetPage(Query<IMedia>()?.Where(x => x.ContentTypeId == contentTypeId), pageIndex, pageSize, out totalRecords, filter, ordering);
|
||||
}
|
||||
|
||||
@@ -441,7 +441,7 @@ namespace Umbraco.Cms.Core.Services
|
||||
}
|
||||
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
|
||||
scope.ReadLock(Constants.Locks.ContentTree);
|
||||
scope.ReadLock(Constants.Locks.MediaTree);
|
||||
return _mediaRepository.GetPage(
|
||||
Query<IMedia>()?.Where(x => contentTypeIds.Contains(x.ContentTypeId)), pageIndex, pageSize, out totalRecords, filter, ordering);
|
||||
}
|
||||
|
||||
@@ -770,16 +770,27 @@ namespace Umbraco.Cms.Core.Services
|
||||
throw new ArgumentException("Cannot save member with empty name.");
|
||||
}
|
||||
|
||||
var previousUsername = _memberRepository.Get(member.Id)?.Username;
|
||||
|
||||
scope.WriteLock(Constants.Locks.MemberTree);
|
||||
|
||||
_memberRepository.Save(member);
|
||||
|
||||
if (publishNotificationSaveOptions.HasFlag(PublishNotificationSaveOptions.Saved))
|
||||
{
|
||||
scope.Notifications.Publish(
|
||||
savingNotification is null
|
||||
// If the user name has changed, populate the previous user name in the additional data, so the cache refreshers
|
||||
// have it available to clear the cache by the old name as well as the new.
|
||||
if (string.IsNullOrWhiteSpace(previousUsername) is false &&
|
||||
string.Equals(previousUsername, member.Username, StringComparison.OrdinalIgnoreCase) is false)
|
||||
{
|
||||
member.AdditionalData![Constants.Entities.AdditionalDataKeys.MemberPreviousUserName] = previousUsername;
|
||||
}
|
||||
|
||||
MemberSavedNotification memberSavedNotification = savingNotification is null
|
||||
? new MemberSavedNotification(member, evtMsgs)
|
||||
: new MemberSavedNotification(member, evtMsgs).WithStateFrom(savingNotification));
|
||||
: new MemberSavedNotification(member, evtMsgs).WithStateFrom(savingNotification);
|
||||
|
||||
scope.Notifications.Publish(memberSavedNotification);
|
||||
}
|
||||
|
||||
Audit(AuditType.Save, 0, member.Id);
|
||||
@@ -820,6 +831,48 @@ namespace Umbraco.Cms.Core.Services
|
||||
scope.Complete();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Note that in this optimized member save operation for use in the login process, where we only handle login related
|
||||
/// properties, we aren't taking any locks. If we were updating "content" properties, that could have relations between each
|
||||
/// other, we should following what we do for documents and lock.
|
||||
/// But here we are just updating these system fields, and it's fine if they work in a "last one wins" fashion without locking.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Note also that we aren't calling "Audit" here (as well as to optimize performance, this is deliberate, because this is not
|
||||
/// a full save operation on the member that we'd want to audit who made the changes via the backoffice or API; rather it's
|
||||
/// just the member logging in as themselves).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// We are though publishing notifications, to maintain backwards compatibility for any solutions using these for
|
||||
/// processing following a member login.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// These notification handlers will ensure that the records to umbracoLog are also added in the same way as they
|
||||
/// are for a full save operation.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task UpdateLoginPropertiesAsync(IMember member)
|
||||
{
|
||||
EventMessages evtMsgs = EventMessagesFactory.Get();
|
||||
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope();
|
||||
var savingNotification = new MemberSavingNotification(member, evtMsgs);
|
||||
savingNotification.State.Add("LoginPropertiesOnly", true);
|
||||
if (scope.Notifications.PublishCancelable(savingNotification))
|
||||
{
|
||||
scope.Complete();
|
||||
return;
|
||||
}
|
||||
|
||||
await _memberRepository.UpdateLoginPropertiesAsync(member);
|
||||
|
||||
scope.Notifications.Publish(new MemberSavedNotification(member, evtMsgs).WithStateFrom(savingNotification));
|
||||
|
||||
scope.Complete();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Delete
|
||||
|
||||
@@ -96,7 +96,7 @@ public class NotificationService : INotificationService
|
||||
|
||||
// see notes above
|
||||
var id = Constants.Security.SuperUserId;
|
||||
const int pagesz = 400; // load batches of 400 users
|
||||
const int UserBatchSize = 400; // load batches of 400 users
|
||||
do
|
||||
{
|
||||
var notifications = GetUsersNotifications(new List<int>(), action, Enumerable.Empty<int>(), Constants.ObjectTypes.Document)?.ToList();
|
||||
@@ -106,10 +106,10 @@ public class NotificationService : INotificationService
|
||||
}
|
||||
|
||||
// users are returned ordered by id, notifications are returned ordered by user id
|
||||
var users = _userService.GetNextUsers(id, pagesz).Where(x => x.IsApproved).ToList();
|
||||
foreach (IUser user in users)
|
||||
var approvedUsers = _userService.GetNextApprovedUsers(id, UserBatchSize).ToList();
|
||||
foreach (IUser approvedUser in approvedUsers)
|
||||
{
|
||||
Notification[] userNotifications = notifications.Where(n => n.UserId == user.Id).ToArray();
|
||||
Notification[] userNotifications = notifications.Where(n => n.UserId == approvedUser.Id).ToArray();
|
||||
foreach (Notification notification in userNotifications)
|
||||
{
|
||||
// notifications are inherited down the tree - find the topmost entity
|
||||
@@ -130,14 +130,14 @@ public class NotificationService : INotificationService
|
||||
}
|
||||
|
||||
// queue notification
|
||||
NotificationRequest req = CreateNotificationRequest(operatingUser, user, entityForNotification, prevVersionDictionary[entityForNotification.Id], actionName, siteUri, createSubject, createBody);
|
||||
NotificationRequest req = CreateNotificationRequest(operatingUser, approvedUser, entityForNotification, prevVersionDictionary[entityForNotification.Id], actionName, siteUri, createSubject, createBody);
|
||||
Enqueue(req);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// load more users if any
|
||||
id = users.Count == pagesz ? users.Last().Id + 1 : -1;
|
||||
id = approvedUsers.Count == UserBatchSize ? approvedUsers.Last().Id + 1 : -1;
|
||||
}
|
||||
while (id > 0);
|
||||
}
|
||||
@@ -385,48 +385,7 @@ public class NotificationService : INotificationService
|
||||
// build summary
|
||||
var summary = new StringBuilder();
|
||||
|
||||
if (content.ContentType.VariesByNothing())
|
||||
{
|
||||
if (!_contentSettings.Notifications.DisableHtmlEmail)
|
||||
{
|
||||
// create the HTML summary for invariant content
|
||||
|
||||
// list all of the property values like we used to
|
||||
summary.Append("<table style=\"width: 100 %; \">");
|
||||
foreach (IProperty p in content.Properties)
|
||||
{
|
||||
// TODO: doesn't take into account variants
|
||||
var newText = p.GetValue() != null ? p.GetValue()?.ToString() : string.Empty;
|
||||
var oldText = newText;
|
||||
|
||||
// check if something was changed and display the changes otherwise display the fields
|
||||
if (oldDoc?.Properties.Contains(p.PropertyType.Alias) ?? false)
|
||||
{
|
||||
IProperty? oldProperty = oldDoc.Properties[p.PropertyType.Alias];
|
||||
oldText = oldProperty?.GetValue() != null ? oldProperty.GetValue()?.ToString() : string.Empty;
|
||||
|
||||
// replace HTML with char equivalent
|
||||
ReplaceHtmlSymbols(ref oldText);
|
||||
ReplaceHtmlSymbols(ref newText);
|
||||
}
|
||||
|
||||
// show the values
|
||||
summary.Append("<tr>");
|
||||
summary.Append(
|
||||
"<th style='text-align: left; vertical-align: top; width: 25%;border-bottom: 1px solid #CCC'>");
|
||||
summary.Append(p.PropertyType.Name);
|
||||
summary.Append("</th>");
|
||||
summary.Append("<td style='text-align: left; vertical-align: top;border-bottom: 1px solid #CCC'>");
|
||||
summary.Append(newText);
|
||||
summary.Append("</td>");
|
||||
summary.Append("</tr>");
|
||||
}
|
||||
|
||||
summary.Append("</table>");
|
||||
}
|
||||
}
|
||||
else if (content.ContentType.VariesByCulture())
|
||||
{
|
||||
if (content.ContentType.VariesByCulture()) {
|
||||
// it's variant, so detect what cultures have changed
|
||||
if (!_contentSettings.Notifications.DisableHtmlEmail)
|
||||
{
|
||||
@@ -465,8 +424,43 @@ public class NotificationService : INotificationService
|
||||
}
|
||||
else
|
||||
{
|
||||
// not supported yet...
|
||||
throw new NotSupportedException();
|
||||
if (!_contentSettings.Notifications.DisableHtmlEmail)
|
||||
{
|
||||
// create the HTML summary for invariant content
|
||||
|
||||
// list all of the property values like we used to
|
||||
summary.Append("<table style=\"width: 100 %; \">");
|
||||
foreach (IProperty p in content.Properties)
|
||||
{
|
||||
// TODO: doesn't take into account variants
|
||||
var newText = p.GetValue() != null ? p.GetValue()?.ToString() : string.Empty;
|
||||
var oldText = newText;
|
||||
|
||||
// check if something was changed and display the changes otherwise display the fields
|
||||
if (oldDoc?.Properties.Contains(p.PropertyType.Alias) ?? false)
|
||||
{
|
||||
IProperty? oldProperty = oldDoc.Properties[p.PropertyType.Alias];
|
||||
oldText = oldProperty?.GetValue() != null ? oldProperty.GetValue()?.ToString() : string.Empty;
|
||||
|
||||
// replace HTML with char equivalent
|
||||
ReplaceHtmlSymbols(ref oldText);
|
||||
ReplaceHtmlSymbols(ref newText);
|
||||
}
|
||||
|
||||
// show the values
|
||||
summary.Append("<tr>");
|
||||
summary.Append(
|
||||
"<th style='text-align: left; vertical-align: top; width: 25%;border-bottom: 1px solid #CCC'>");
|
||||
summary.Append(p.PropertyType.Name);
|
||||
summary.Append("</th>");
|
||||
summary.Append("<td style='text-align: left; vertical-align: top;border-bottom: 1px solid #CCC'>");
|
||||
summary.Append(newText);
|
||||
summary.Append("</td>");
|
||||
summary.Append("</tr>");
|
||||
}
|
||||
|
||||
summary.Append("</table>");
|
||||
}
|
||||
}
|
||||
|
||||
var protocol = _globalSettings.UseHttps ? "https" : "http";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Persistence.Repositories;
|
||||
@@ -146,5 +145,5 @@ internal class RedirectUrlService : RepositoryService, IRedirectUrlService
|
||||
{
|
||||
return await _redirectUrlRepository.GetMostRecentUrlAsync(url, culture);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -712,6 +712,7 @@ internal class UserService : RepositoryService, IUserService
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<IUser> GetNextUsers(int id, int count)
|
||||
{
|
||||
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
|
||||
@@ -720,6 +721,25 @@ internal class UserService : RepositoryService, IUserService
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<IUser> GetNextApprovedUsers(int id, int count)
|
||||
{
|
||||
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
|
||||
{
|
||||
return _userRepository.GetNextApprovedUsers(id, count);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void InvalidateSessionsForRemovedProviders(IEnumerable<string> currentLoginProviders)
|
||||
{
|
||||
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
|
||||
{
|
||||
_userRepository.InvalidateSessionsForRemovedProviders(currentLoginProviders);
|
||||
scope.Complete();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a list of <see cref="IUser" /> objects associated with a given group
|
||||
/// </summary>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Cms.Core.Routing;
|
||||
using Umbraco.Cms.Core.Web;
|
||||
|
||||
@@ -43,7 +44,17 @@ public sealed class HtmlLocalLinkParser
|
||||
/// <param name="text"></param>
|
||||
/// <param name="preview"></param>
|
||||
/// <returns></returns>
|
||||
public string EnsureInternalLinks(string text, bool preview)
|
||||
public string EnsureInternalLinks(string text, bool preview) =>
|
||||
EnsureInternalLinks(text, preview, UrlMode.Default);
|
||||
|
||||
/// <summary>
|
||||
/// Parses the string looking for the {localLink} syntax and updates them to their correct links.
|
||||
/// </summary>
|
||||
/// <param name="text"></param>
|
||||
/// <param name="preview"></param>
|
||||
/// <param name="urlMode"></param>
|
||||
/// <returns></returns>
|
||||
public string EnsureInternalLinks(string text, bool preview, UrlMode urlMode)
|
||||
{
|
||||
if (!_umbracoContextAccessor.TryGetUmbracoContext(out IUmbracoContext? umbracoContext))
|
||||
{
|
||||
@@ -52,12 +63,12 @@ public sealed class HtmlLocalLinkParser
|
||||
|
||||
if (!preview)
|
||||
{
|
||||
return EnsureInternalLinks(text);
|
||||
return EnsureInternalLinks(text, urlMode);
|
||||
}
|
||||
|
||||
using (umbracoContext.ForcedPreview(preview)) // force for URL provider
|
||||
{
|
||||
return EnsureInternalLinks(text);
|
||||
return EnsureInternalLinks(text, urlMode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +77,16 @@ public sealed class HtmlLocalLinkParser
|
||||
/// </summary>
|
||||
/// <param name="text"></param>
|
||||
/// <returns></returns>
|
||||
public string EnsureInternalLinks(string text)
|
||||
public string EnsureInternalLinks(string text) =>
|
||||
EnsureInternalLinks(text, UrlMode.Default);
|
||||
|
||||
/// <summary>
|
||||
/// Parses the string looking for the {localLink} syntax and updates them to their correct links.
|
||||
/// </summary>
|
||||
/// <param name="text"></param>
|
||||
/// <param name="urlMode"></param>
|
||||
/// <returns></returns>
|
||||
public string EnsureInternalLinks(string text, UrlMode urlMode)
|
||||
{
|
||||
if (!_umbracoContextAccessor.TryGetUmbracoContext(out _))
|
||||
{
|
||||
@@ -80,11 +100,11 @@ public sealed class HtmlLocalLinkParser
|
||||
var newLink = "#";
|
||||
if (udi?.EntityType == Constants.UdiEntityType.Document)
|
||||
{
|
||||
newLink = _publishedUrlProvider.GetUrl(udi.Guid);
|
||||
newLink = _publishedUrlProvider.GetUrl(udi.Guid, urlMode);
|
||||
}
|
||||
else if (udi?.EntityType == Constants.UdiEntityType.Media)
|
||||
{
|
||||
newLink = _publishedUrlProvider.GetMediaUrl(udi.Guid);
|
||||
newLink = _publishedUrlProvider.GetMediaUrl(udi.Guid, urlMode);
|
||||
}
|
||||
|
||||
if (newLink == null)
|
||||
@@ -96,7 +116,7 @@ public sealed class HtmlLocalLinkParser
|
||||
}
|
||||
else if (intId.HasValue)
|
||||
{
|
||||
var newLink = _publishedUrlProvider.GetUrl(intId.Value);
|
||||
var newLink = _publishedUrlProvider.GetUrl(intId.Value, urlMode);
|
||||
text = text.Replace(tagValue, "href=\"" + newLink);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
namespace Umbraco.Cms.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Makes a code block timed (take at least a certain amount of time). This class cannot be inherited.
|
||||
/// </summary>
|
||||
public sealed class TimedScope : IDisposable, IAsyncDisposable
|
||||
{
|
||||
private readonly TimeSpan _duration;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly CancellationTokenSource _cancellationTokenSource;
|
||||
private readonly long _startingTimestamp;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the elapsed time.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// The elapsed time.
|
||||
/// </value>
|
||||
public TimeSpan Elapsed
|
||||
=> _timeProvider.GetElapsedTime(_startingTimestamp);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the remaining time.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// The remaining time.
|
||||
/// </value>
|
||||
public TimeSpan Remaining
|
||||
=> TryGetRemaining(out TimeSpan remaining) ? remaining : TimeSpan.Zero;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimedScope" /> class.
|
||||
/// </summary>
|
||||
/// <param name="millisecondsDuration">The number of milliseconds the scope should at least take.</param>
|
||||
public TimedScope(long millisecondsDuration)
|
||||
: this(TimeSpan.FromMilliseconds(millisecondsDuration))
|
||||
{ }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimedScope" /> class.
|
||||
/// </summary>
|
||||
/// <param name="millisecondsDuration">The number of milliseconds the scope should at least take.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
public TimedScope(long millisecondsDuration, CancellationToken cancellationToken)
|
||||
: this(TimeSpan.FromMilliseconds(millisecondsDuration), cancellationToken)
|
||||
{ }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimedScope" /> class.
|
||||
/// </summary>
|
||||
/// <param name="millisecondsDuration">The number of milliseconds the scope should at least take.</param>
|
||||
/// <param name="timeProvider">The time provider.</param>
|
||||
public TimedScope(long millisecondsDuration, TimeProvider timeProvider)
|
||||
: this(TimeSpan.FromMilliseconds(millisecondsDuration), timeProvider)
|
||||
{ }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimedScope" /> class.
|
||||
/// </summary>
|
||||
/// <param name="millisecondsDuration">The number of milliseconds the scope should at least take.</param>
|
||||
/// <param name="timeProvider">The time provider.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
public TimedScope(long millisecondsDuration, TimeProvider timeProvider, CancellationToken cancellationToken)
|
||||
: this(TimeSpan.FromMilliseconds(millisecondsDuration), timeProvider, cancellationToken)
|
||||
{ }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimedScope"/> class.
|
||||
/// </summary>
|
||||
/// <param name="duration">The duration the scope should at least take.</param>
|
||||
public TimedScope(TimeSpan duration)
|
||||
: this(duration, TimeProvider.System)
|
||||
{ }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimedScope" /> class.
|
||||
/// </summary>
|
||||
/// <param name="duration">The duration the scope should at least take.</param>
|
||||
/// <param name="timeProvider">The time provider.</param>
|
||||
public TimedScope(TimeSpan duration, TimeProvider timeProvider)
|
||||
: this(duration, timeProvider, new CancellationTokenSource())
|
||||
{ }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimedScope" /> class.
|
||||
/// </summary>
|
||||
/// <param name="duration">The duration the scope should at least take.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
public TimedScope(TimeSpan duration, CancellationToken cancellationToken)
|
||||
: this(duration, TimeProvider.System, cancellationToken)
|
||||
{ }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TimedScope" /> class.
|
||||
/// </summary>
|
||||
/// <param name="duration">The duration the scope should at least take.</param>
|
||||
/// <param name="timeProvider">The time provider.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
public TimedScope(TimeSpan duration, TimeProvider timeProvider, CancellationToken cancellationToken)
|
||||
: this(duration, timeProvider, CancellationTokenSource.CreateLinkedTokenSource(cancellationToken))
|
||||
{ }
|
||||
|
||||
private TimedScope(TimeSpan duration, TimeProvider timeProvider, CancellationTokenSource cancellationTokenSource)
|
||||
{
|
||||
_duration = duration;
|
||||
_timeProvider = timeProvider;
|
||||
_cancellationTokenSource = cancellationTokenSource;
|
||||
_startingTimestamp = timeProvider.GetTimestamp();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels the timed scope.
|
||||
/// </summary>
|
||||
public void Cancel()
|
||||
=> _cancellationTokenSource.Cancel();
|
||||
|
||||
/// <summary>
|
||||
/// Cancels the timed scope asynchronously.
|
||||
/// </summary>
|
||||
public async Task CancelAsync()
|
||||
=> await _cancellationTokenSource.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
/// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This will block using <see cref="Thread.Sleep(TimeSpan)" /> until the remaining time has elapsed, if not cancelled.
|
||||
/// </remarks>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_cancellationTokenSource.IsCancellationRequested is false &&
|
||||
TryGetRemaining(out TimeSpan remaining))
|
||||
{
|
||||
Thread.Sleep(remaining);
|
||||
}
|
||||
|
||||
_cancellationTokenSource.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources asynchronously.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// A task that represents the asynchronous dispose operation.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// This will delay using <see cref="Task.Delay(TimeSpan, TimeProvider, CancellationToken)" /> until the remaining time has elapsed, if not cancelled.
|
||||
/// </remarks>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_cancellationTokenSource.IsCancellationRequested is false &&
|
||||
TryGetRemaining(out TimeSpan remaining))
|
||||
{
|
||||
await Task.Delay(remaining, _timeProvider, _cancellationTokenSource.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_cancellationTokenSource.Dispose();
|
||||
}
|
||||
|
||||
private bool TryGetRemaining(out TimeSpan remaining)
|
||||
{
|
||||
remaining = _duration.Subtract(Elapsed);
|
||||
|
||||
return remaining > TimeSpan.Zero;
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,7 @@ public class BackOfficeExamineSearcher : IBackOfficeExamineSearcher
|
||||
private readonly IEntityService _entityService;
|
||||
private readonly IExamineManager _examineManager;
|
||||
private readonly ILocalizationService _languageService;
|
||||
private readonly IPublishedUrlProvider _publishedUrlProvider;
|
||||
private readonly IUmbracoTreeSearcherFields _treeSearcherFields;
|
||||
private readonly IUmbracoMapper _umbracoMapper;
|
||||
|
||||
public BackOfficeExamineSearcher(
|
||||
IExamineManager examineManager,
|
||||
@@ -48,8 +46,6 @@ public class BackOfficeExamineSearcher : IBackOfficeExamineSearcher
|
||||
_entityService = entityService;
|
||||
_treeSearcherFields = treeSearcherFields;
|
||||
_appCaches = appCaches;
|
||||
_umbracoMapper = umbracoMapper;
|
||||
_publishedUrlProvider = publishedUrlProvider;
|
||||
}
|
||||
|
||||
public IEnumerable<ISearchResult> Search(
|
||||
@@ -82,8 +78,6 @@ public class BackOfficeExamineSearcher : IBackOfficeExamineSearcher
|
||||
query = "\"" + g + "\"";
|
||||
}
|
||||
|
||||
IUser? currentUser = _backOfficeSecurityAccessor?.BackOfficeSecurity?.CurrentUser;
|
||||
|
||||
switch (entityType)
|
||||
{
|
||||
case UmbracoEntityTypes.Member:
|
||||
@@ -96,7 +90,7 @@ public class BackOfficeExamineSearcher : IBackOfficeExamineSearcher
|
||||
}
|
||||
|
||||
if (searchFrom != null && searchFrom != Constants.Conventions.MemberTypes.AllMembersListId &&
|
||||
searchFrom.Trim() != "-1")
|
||||
searchFrom.Trim() != Constants.System.RootString)
|
||||
{
|
||||
sb.Append("+__NodeTypeAlias:");
|
||||
sb.Append(searchFrom);
|
||||
@@ -112,10 +106,13 @@ public class BackOfficeExamineSearcher : IBackOfficeExamineSearcher
|
||||
fieldsToLoad.Add(field);
|
||||
}
|
||||
|
||||
var allMediaStartNodes = currentUser != null
|
||||
? currentUser.CalculateMediaStartNodeIds(_entityService, _appCaches)
|
||||
: Array.Empty<int>();
|
||||
AppendPath(sb, UmbracoObjectTypes.Media, allMediaStartNodes, searchFrom, ignoreUserStartNodes, _entityService);
|
||||
AppendPath(sb, UmbracoObjectTypes.Media, searchFrom, ignoreUserStartNodes, out var abortMediaQuery);
|
||||
if (abortMediaQuery)
|
||||
{
|
||||
totalFound = 0;
|
||||
return [];
|
||||
}
|
||||
|
||||
break;
|
||||
case UmbracoEntityTypes.Document:
|
||||
type = "content";
|
||||
@@ -125,10 +122,13 @@ public class BackOfficeExamineSearcher : IBackOfficeExamineSearcher
|
||||
fieldsToLoad.Add(field);
|
||||
}
|
||||
|
||||
var allContentStartNodes = currentUser != null
|
||||
? currentUser.CalculateContentStartNodeIds(_entityService, _appCaches)
|
||||
: Array.Empty<int>();
|
||||
AppendPath(sb, UmbracoObjectTypes.Document, allContentStartNodes, searchFrom, ignoreUserStartNodes, _entityService);
|
||||
AppendPath(sb, UmbracoObjectTypes.Document, searchFrom, ignoreUserStartNodes, out var abortContentQuery);
|
||||
if (abortContentQuery)
|
||||
{
|
||||
totalFound = 0;
|
||||
return [];
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException("The " + typeof(BackOfficeExamineSearcher) +
|
||||
@@ -344,67 +344,89 @@ public class BackOfficeExamineSearcher : IBackOfficeExamineSearcher
|
||||
}
|
||||
}
|
||||
|
||||
private void AppendPath(StringBuilder sb, UmbracoObjectTypes objectType, int[]? startNodeIds, string? searchFrom, bool ignoreUserStartNodes, IEntityService entityService)
|
||||
private void AppendPath(StringBuilder sb, UmbracoObjectTypes objectType, string? searchFrom, bool ignoreUserStartNodes, out bool abortQuery)
|
||||
{
|
||||
if (sb == null)
|
||||
ArgumentNullException.ThrowIfNull(sb);
|
||||
|
||||
abortQuery = false;
|
||||
|
||||
if (searchFrom is Constants.System.RootString)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(sb));
|
||||
searchFrom = null;
|
||||
}
|
||||
|
||||
if (entityService == null)
|
||||
var userStartNodes = ignoreUserStartNodes ? [Constants.System.Root] : GetUserStartNodes(objectType);
|
||||
if (searchFrom is null && userStartNodes.Contains(Constants.System.Root))
|
||||
{
|
||||
throw new ArgumentNullException(nameof(entityService));
|
||||
// If we have no searchFrom and the user either has access to the root node or we are ignoring user
|
||||
// start nodes, we don't need to filter by path.
|
||||
return;
|
||||
}
|
||||
|
||||
UdiParser.TryParse(searchFrom, true, out Udi? udi);
|
||||
searchFrom = udi == null ? searchFrom : entityService.GetId(udi).Result.ToString();
|
||||
|
||||
TreeEntityPath? entityPath =
|
||||
int.TryParse(searchFrom, NumberStyles.Integer, CultureInfo.InvariantCulture, out var searchFromId) &&
|
||||
searchFromId > 0
|
||||
? entityService.GetAllPaths(objectType, searchFromId).FirstOrDefault()
|
||||
: null;
|
||||
if (entityPath != null)
|
||||
string[] pathsToFilter;
|
||||
if (searchFrom is null)
|
||||
{
|
||||
// find... only what's underneath
|
||||
sb.Append("+__Path:");
|
||||
AppendPath(sb, entityPath.Path, false);
|
||||
sb.Append(" ");
|
||||
// If we don't want to filter by a specific entity, we can simply use the user start nodes.
|
||||
pathsToFilter = GetEntityPaths(objectType, userStartNodes);
|
||||
}
|
||||
else if (startNodeIds?.Length == 0)
|
||||
else
|
||||
{
|
||||
// make sure we don't find anything
|
||||
sb.Append("+__Path:none ");
|
||||
}
|
||||
else if (startNodeIds?.Contains(-1) == false && ignoreUserStartNodes == false) // -1 = no restriction
|
||||
{
|
||||
IEnumerable<TreeEntityPath> entityPaths = entityService.GetAllPaths(objectType, startNodeIds);
|
||||
|
||||
// for each start node, find the start node, and what's underneath
|
||||
// +__Path:(-1*,1234 -1*,1234,* -1*,5678 -1*,5678,* ...)
|
||||
sb.Append("+__Path:(");
|
||||
var first = true;
|
||||
foreach (TreeEntityPath ep in entityPaths)
|
||||
TreeEntityPath? searchFromPath = GetEntityPath(searchFrom, objectType);
|
||||
if (searchFromPath is null)
|
||||
{
|
||||
if (first)
|
||||
{
|
||||
first = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(" ");
|
||||
}
|
||||
|
||||
AppendPath(sb, ep.Path, true);
|
||||
// If the searchFrom cannot be found, return no results.
|
||||
// This is to prevent showing entities outside the intended filter.
|
||||
abortQuery = true;
|
||||
return;
|
||||
}
|
||||
|
||||
sb.Append(") ");
|
||||
var userStartNodePaths = GetEntityPaths(objectType, userStartNodes);
|
||||
|
||||
// If the user has access to the entity, we can simply filter by the entity path.
|
||||
if (userStartNodePaths.Any(userStartNodePath => StartsWithPath(searchFromPath.Path, userStartNodePath)))
|
||||
{
|
||||
sb.Append("+__Path:");
|
||||
AppendPath(sb, searchFromPath.Path, false);
|
||||
sb.Append(' ');
|
||||
return;
|
||||
}
|
||||
|
||||
// If the user does not have access to the entity, let's filter the paths by the ones that start with the
|
||||
// entity path (are descendants of the entity).
|
||||
pathsToFilter = userStartNodePaths.Where(ep => StartsWithPath(ep, searchFromPath.Path)).ToArray();
|
||||
}
|
||||
|
||||
// If we have no paths left, no need to perform the query at all, just return no results.
|
||||
if (pathsToFilter.Length == 0)
|
||||
{
|
||||
abortQuery = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// For each start node, find the start node, and what's underneath
|
||||
// +__Path:(-1*,1234 -1*,1234,* -1*,5678 -1*,5678,* ...)
|
||||
sb.Append("+__Path:(");
|
||||
var first = true;
|
||||
foreach (string pathToFilter in pathsToFilter)
|
||||
{
|
||||
if (first)
|
||||
{
|
||||
first = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(' ');
|
||||
}
|
||||
|
||||
AppendPath(sb, pathToFilter, true);
|
||||
}
|
||||
|
||||
sb.Append(") ");
|
||||
}
|
||||
|
||||
private void AppendPath(StringBuilder sb, string path, bool includeThisNode)
|
||||
private static void AppendPath(StringBuilder sb, string path, bool includeThisNode)
|
||||
{
|
||||
path = path.Replace("-", "\\-").Replace(",", "\\,");
|
||||
path = path.Replace("-", "\\-");
|
||||
if (includeThisNode)
|
||||
{
|
||||
sb.Append(path);
|
||||
@@ -412,6 +434,68 @@ public class BackOfficeExamineSearcher : IBackOfficeExamineSearcher
|
||||
}
|
||||
|
||||
sb.Append(path);
|
||||
sb.Append("\\,*");
|
||||
sb.Append(",*");
|
||||
}
|
||||
|
||||
private static bool StartsWithPath(string path1, string path2)
|
||||
{
|
||||
if (path1.StartsWith(path2) == false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return path1.Length == path2.Length || path1[path2.Length] == ',';
|
||||
}
|
||||
|
||||
private int[] GetUserStartNodes(UmbracoObjectTypes objectType)
|
||||
{
|
||||
IUser? currentUser = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser;
|
||||
if (currentUser is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var startNodes = objectType switch
|
||||
{
|
||||
UmbracoObjectTypes.Document => currentUser.CalculateContentStartNodeIds(_entityService, _appCaches),
|
||||
UmbracoObjectTypes.Media => currentUser.CalculateMediaStartNodeIds(_entityService, _appCaches),
|
||||
_ => throw new NotSupportedException($"The object type {objectType} is not supported for start nodes."),
|
||||
};
|
||||
|
||||
return startNodes ?? [Constants.System.Root]; // If no start nodes are defined, we assume the user has access to the root node (-1).
|
||||
}
|
||||
|
||||
private string[] GetEntityPaths(UmbracoObjectTypes objectType, int[] entityIds) =>
|
||||
entityIds switch
|
||||
{
|
||||
[] => [],
|
||||
_ when entityIds.Contains(Constants.System.Root) => [Constants.System.RootString],
|
||||
_ => _entityService.GetAllPaths(objectType, entityIds).Select(x => x.Path).ToArray(),
|
||||
};
|
||||
|
||||
private TreeEntityPath? GetEntityPath(string? searchFrom, UmbracoObjectTypes objectType)
|
||||
{
|
||||
if (searchFrom is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Guid? entityKey = null;
|
||||
if (Guid.TryParse(searchFrom, out Guid entityGuid))
|
||||
{
|
||||
entityKey = entityGuid;
|
||||
} // fallback to Udi for legacy reasons as the calling methods take string?
|
||||
else if (UdiParser.TryParse(searchFrom, true, out Udi? udi) && udi is GuidUdi guidUdi)
|
||||
{
|
||||
entityKey = guidUdi.Guid;
|
||||
}
|
||||
else if (int.TryParse(searchFrom, NumberStyles.Integer, CultureInfo.InvariantCulture, out var entityId)
|
||||
&& entityId > 0
|
||||
&& _entityService.GetKey(entityId, objectType) is { Success: true } attempt)
|
||||
{
|
||||
entityKey = attempt.Result;
|
||||
}
|
||||
|
||||
return entityKey is null ? null : _entityService.GetAllPaths(objectType, entityKey.Value).FirstOrDefault();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,6 @@ public class DefaultRepositoryCachePolicy<TEntity, TId> : RepositoryCachePolicyB
|
||||
private static readonly TEntity[] _emptyEntities = new TEntity[0]; // const
|
||||
private readonly RepositoryCachePolicyOptions _options;
|
||||
|
||||
private const string NullRepresentationInCache = "*NULL*";
|
||||
|
||||
public DefaultRepositoryCachePolicy(IAppPolicyCache cache, IScopeAccessor scopeAccessor, RepositoryCachePolicyOptions options)
|
||||
: base(cache, scopeAccessor) =>
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
@@ -139,10 +137,8 @@ public class DefaultRepositoryCachePolicy<TEntity, TId> : RepositoryCachePolicyB
|
||||
return fromCache;
|
||||
}
|
||||
|
||||
// Because TEntity can never be a string, we will never be in a position where the proxy value collides withs a real value.
|
||||
// Therefore this point can only be reached if there is a proxy null value => becomes null when cast to TEntity above OR the item simply does not exist.
|
||||
// If we've cached a "null" value, return null.
|
||||
if (_options.CacheNullValues && Cache.GetCacheItem<string>(cacheKey) == NullRepresentationInCache)
|
||||
if (_options.CacheNullValues && Cache.GetCacheItem<string>(cacheKey) == Constants.Cache.NullRepresentationInCache)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
@@ -273,7 +269,7 @@ public class DefaultRepositoryCachePolicy<TEntity, TId> : RepositoryCachePolicyB
|
||||
// a value that does exist but isn't yet cached, or a value that has been explicitly cached with a null value.
|
||||
// Both would return null when we retrieve from the cache and we couldn't distinguish between the two.
|
||||
// So we cache a special value that represents null, and then we can check for that value when we retrieve from the cache.
|
||||
Cache.Insert(cacheKey, () => NullRepresentationInCache, TimeSpan.FromMinutes(5), true);
|
||||
Cache.Insert(cacheKey, () => Constants.Cache.NullRepresentationInCache, TimeSpan.FromMinutes(5), true);
|
||||
}
|
||||
|
||||
protected virtual void InsertEntities(TId[]? ids, TEntity[]? entities)
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
using HtmlAgilityPack;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using HtmlAgilityPack;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.DeliveryApi;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Models.Blocks;
|
||||
using Umbraco.Cms.Core.Models.DeliveryApi;
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
using Umbraco.Cms.Core.Routing;
|
||||
using Umbraco.Cms.Infrastructure.Extensions;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
@@ -101,9 +98,9 @@ internal sealed class ApiRichTextElementParser : ApiRichTextParserBase, IApiRich
|
||||
// - non-#comment nodes
|
||||
// - non-#text nodes
|
||||
// - non-empty #text nodes
|
||||
// - empty #text between inline elements (see #17037)
|
||||
// - empty #text between inline elements (see #17037) but not #text with only newlines (see #19388)
|
||||
HtmlNode[] childNodes = element.ChildNodes
|
||||
.Where(c => c.Name != CommentNodeName && (c.Name != TextNodeName || c.NextSibling is not null || string.IsNullOrWhiteSpace(c.InnerText) is false))
|
||||
.Where(c => c.Name != CommentNodeName && (c.Name != TextNodeName || IsNonEmptyElement(c)))
|
||||
.ToArray();
|
||||
|
||||
var tag = TagName(element);
|
||||
@@ -124,6 +121,9 @@ internal sealed class ApiRichTextElementParser : ApiRichTextParserBase, IApiRich
|
||||
return createElement(tag, attributes, childElements);
|
||||
}
|
||||
|
||||
private static bool IsNonEmptyElement(HtmlNode htmlNode) =>
|
||||
string.IsNullOrWhiteSpace(htmlNode.InnerText) is false || htmlNode.InnerText.Any(c => c != '\n' && c != '\r');
|
||||
|
||||
private string TagName(HtmlNode htmlNode) => htmlNode.Name;
|
||||
|
||||
private void ReplaceLocalLinks(IPublishedSnapshot publishedSnapshot, Dictionary<string, object> attributes)
|
||||
|
||||
@@ -56,7 +56,7 @@ internal sealed class ApiRichTextMarkupParser : ApiRichTextParserBase, IApiRichT
|
||||
link.GetAttributeValue("href", string.Empty),
|
||||
route =>
|
||||
{
|
||||
link.SetAttributeValue("href", route.Path);
|
||||
link.SetAttributeValue("href", $"{route.Path}{route.QueryString}");
|
||||
link.SetAttributeValue("data-start-item-path", route.StartItem.Path);
|
||||
link.SetAttributeValue("data-start-item-id", route.StartItem.Id.ToString("D"));
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ using Umbraco.Cms.Core.DeliveryApi;
|
||||
using Umbraco.Cms.Core.Models.DeliveryApi;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.DeliveryApi;
|
||||
|
||||
@@ -41,6 +42,7 @@ internal abstract partial class ApiRichTextParserBase
|
||||
: null;
|
||||
if (route != null)
|
||||
{
|
||||
route.QueryString = match.Groups["query"].Value.NullOrWhiteSpaceAsNull();
|
||||
handled = true;
|
||||
handleContentRoute(route);
|
||||
}
|
||||
@@ -79,6 +81,6 @@ internal abstract partial class ApiRichTextParserBase
|
||||
handleMediaUrl(_apiMediaUrlProvider.GetUrl(media));
|
||||
}
|
||||
|
||||
[GeneratedRegex("{localLink:(?<udi>umb:.+)}")]
|
||||
[GeneratedRegex("{localLink:(?<udi>umb:.+)}(?<query>[^\"]*)")]
|
||||
private static partial Regex LocalLinkRegex();
|
||||
}
|
||||
|
||||
@@ -240,6 +240,7 @@ public static partial class UmbracoBuilderExtensions
|
||||
builder.Services.AddSingleton<IBlockEditorElementTypeCache, BlockEditorElementTypeCache>();
|
||||
|
||||
builder.Services.AddSingleton<IRichTextRequiredValidator, RichTextRequiredValidator>();
|
||||
builder.Services.AddSingleton<IRichTextRegexValidator, RichTextRegexValidator>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Persistence.Querying;
|
||||
@@ -28,21 +28,28 @@ internal sealed class DeliveryApiContentIndexHelper : IDeliveryApiContentIndexHe
|
||||
public void EnumerateApplicableDescendantsForContentIndex(int rootContentId, Action<IContent[]> actionToPerform)
|
||||
{
|
||||
const int pageSize = 10000;
|
||||
var pageIndex = 0;
|
||||
EnumerateApplicableDescendantsForContentIndex(rootContentId, actionToPerform, pageSize);
|
||||
}
|
||||
|
||||
internal void EnumerateApplicableDescendantsForContentIndex(int rootContentId, Action<IContent[]> actionToPerform, int pageSize)
|
||||
{
|
||||
var itemIndex = 0;
|
||||
long total;
|
||||
|
||||
IQuery<IContent> query = _umbracoDatabaseFactory.SqlContext.Query<IContent>().Where(content => content.Trashed == false);
|
||||
|
||||
IContent[] descendants;
|
||||
IQuery<IContent> query = _umbracoDatabaseFactory.SqlContext.Query<IContent>().Where(content => content.Trashed == false);
|
||||
do
|
||||
{
|
||||
descendants = _contentService
|
||||
.GetPagedDescendants(rootContentId, pageIndex, pageSize, out _, query, Ordering.By("Path"))
|
||||
.GetPagedDescendants(rootContentId, itemIndex / pageSize, pageSize, out total, query, Ordering.By("Path"))
|
||||
.Where(descendant => _deliveryApiSettings.IsAllowedContentType(descendant.ContentType.Alias))
|
||||
.ToArray();
|
||||
|
||||
actionToPerform(descendants.ToArray());
|
||||
actionToPerform(descendants);
|
||||
|
||||
pageIndex++;
|
||||
itemIndex += pageSize;
|
||||
}
|
||||
while (descendants.Length == pageSize);
|
||||
while (descendants.Length > 0 && itemIndex < total);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ public interface IPublishedContentQuery
|
||||
|
||||
IEnumerable<IPublishedContent> ContentAtRoot();
|
||||
|
||||
IEnumerable<IPublishedContent> ContentAtRoot(string? culture) => culture is null ? ContentAtRoot() : throw new NotSupportedException();
|
||||
|
||||
IPublishedContent? Media(int id);
|
||||
|
||||
IPublishedContent? Media(Guid id);
|
||||
|
||||
+4
-2
@@ -5,6 +5,7 @@ using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Persistence.Querying;
|
||||
using Umbraco.Cms.Core.Persistence.Repositories;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Strings;
|
||||
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
|
||||
using Umbraco.Cms.Infrastructure.Persistence.Querying;
|
||||
@@ -25,8 +26,9 @@ internal class ContentTypeRepository : ContentTypeRepositoryBase<IContentType>,
|
||||
ILogger<ContentTypeRepository> logger,
|
||||
IContentTypeCommonRepository commonRepository,
|
||||
ILanguageRepository languageRepository,
|
||||
IShortStringHelper shortStringHelper)
|
||||
: base(scopeAccessor, cache, logger, commonRepository, languageRepository, shortStringHelper)
|
||||
IShortStringHelper shortStringHelper,
|
||||
Lazy<IIdKeyMap> idKeyMap)
|
||||
: base(scopeAccessor, cache, logger, commonRepository, languageRepository, shortStringHelper, idKeyMap)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
+54
-25
@@ -29,15 +29,22 @@ internal abstract class ContentTypeRepositoryBase<TEntity> : EntityRepositoryBas
|
||||
where TEntity : class, IContentTypeComposition
|
||||
{
|
||||
private readonly IShortStringHelper _shortStringHelper;
|
||||
private readonly Lazy<IIdKeyMap> _idKeyMap;
|
||||
|
||||
protected ContentTypeRepositoryBase(IScopeAccessor scopeAccessor, AppCaches cache,
|
||||
ILogger<ContentTypeRepositoryBase<TEntity>> logger, IContentTypeCommonRepository commonRepository,
|
||||
ILanguageRepository languageRepository, IShortStringHelper shortStringHelper)
|
||||
protected ContentTypeRepositoryBase(
|
||||
IScopeAccessor scopeAccessor,
|
||||
AppCaches cache,
|
||||
ILogger<ContentTypeRepositoryBase<TEntity>> logger,
|
||||
IContentTypeCommonRepository commonRepository,
|
||||
ILanguageRepository languageRepository,
|
||||
IShortStringHelper shortStringHelper,
|
||||
Lazy<IIdKeyMap> idKeyMap)
|
||||
: base(scopeAccessor, cache, logger)
|
||||
{
|
||||
_shortStringHelper = shortStringHelper;
|
||||
CommonRepository = commonRepository;
|
||||
LanguageRepository = languageRepository;
|
||||
_idKeyMap = idKeyMap;
|
||||
}
|
||||
|
||||
protected IContentTypeCommonRepository CommonRepository { get; }
|
||||
@@ -287,7 +294,7 @@ AND umbracoNode.nodeObjectType = @objectType",
|
||||
// If the Id of the DataType is not set, we resolve it from the db by its PropertyEditorAlias
|
||||
if (propertyType.DataTypeId == 0 || propertyType.DataTypeId == default)
|
||||
{
|
||||
AssignDataTypeFromPropertyEditor(propertyType);
|
||||
AssignDataTypeIdFromProvidedKeyOrPropertyEditor(propertyType);
|
||||
}
|
||||
|
||||
PropertyTypeDto propertyTypeDto =
|
||||
@@ -590,7 +597,7 @@ AND umbracoNode.id <> @id",
|
||||
// if the Id of the DataType is not set, we resolve it from the db by its PropertyEditorAlias
|
||||
if (propertyType.DataTypeId == 0 || propertyType.DataTypeId == default)
|
||||
{
|
||||
AssignDataTypeFromPropertyEditor(propertyType);
|
||||
AssignDataTypeIdFromProvidedKeyOrPropertyEditor(propertyType);
|
||||
}
|
||||
|
||||
// validate the alias
|
||||
@@ -1434,37 +1441,59 @@ AND umbracoNode.id <> @id",
|
||||
protected abstract TEntity? PerformGet(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Try to set the data type id based on its ControlId
|
||||
/// Try to set the data type Id based on the provided key or property editor alias.
|
||||
/// </summary>
|
||||
/// <param name="propertyType"></param>
|
||||
private void AssignDataTypeFromPropertyEditor(IPropertyType propertyType)
|
||||
private void AssignDataTypeIdFromProvidedKeyOrPropertyEditor(IPropertyType propertyType)
|
||||
{
|
||||
// we cannot try to assign a data type of it's empty
|
||||
if (propertyType.PropertyEditorAlias.IsNullOrWhiteSpace() == false)
|
||||
// If a key is provided, use that.
|
||||
if (propertyType.DataTypeKey != Guid.Empty)
|
||||
{
|
||||
Sql<ISqlContext> sql = Sql()
|
||||
.Select<DataTypeDto>(dt => dt.Select(x => x.NodeDto))
|
||||
.From<DataTypeDto>()
|
||||
.InnerJoin<NodeDto>().On<DataTypeDto, NodeDto>((dt, n) => dt.NodeId == n.NodeId)
|
||||
.Where(
|
||||
"propertyEditorAlias = @propertyEditorAlias",
|
||||
new { propertyEditorAlias = propertyType.PropertyEditorAlias })
|
||||
.OrderBy<DataTypeDto>(typeDto => typeDto.NodeId);
|
||||
DataTypeDto? datatype = Database.FirstOrDefault<DataTypeDto>(sql);
|
||||
|
||||
// we cannot assign a data type if one was not found
|
||||
if (datatype != null)
|
||||
Attempt<int> dataTypeIdAttempt = _idKeyMap.Value.GetIdForKey(propertyType.DataTypeKey, UmbracoObjectTypes.DataType);
|
||||
if (dataTypeIdAttempt.Success)
|
||||
{
|
||||
propertyType.DataTypeId = datatype.NodeId;
|
||||
propertyType.DataTypeKey = datatype.NodeDto.UniqueId;
|
||||
propertyType.DataTypeId = dataTypeIdAttempt.Result;
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning(
|
||||
"Could not assign a data type for the property type {PropertyTypeAlias} since no data type was found with a property editor {PropertyEditorAlias}",
|
||||
propertyType.Alias, propertyType.PropertyEditorAlias);
|
||||
"Could not assign a data type for the property type {PropertyTypeAlias} since no integer Id was found matching the key {DataTypeKey}. Falling back to look up via the property editor alias.",
|
||||
propertyType.Alias,
|
||||
propertyType.DataTypeKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise if a property editor alias is provided, try to find a data type that uses that alias.
|
||||
if (propertyType.PropertyEditorAlias.IsNullOrWhiteSpace())
|
||||
{
|
||||
// We cannot try to assign a data type if it's empty.
|
||||
return;
|
||||
}
|
||||
|
||||
Sql<ISqlContext> sql = Sql()
|
||||
.Select<DataTypeDto>(dt => dt.Select(x => x.NodeDto))
|
||||
.From<DataTypeDto>()
|
||||
.InnerJoin<NodeDto>().On<DataTypeDto, NodeDto>((dt, n) => dt.NodeId == n.NodeId)
|
||||
.Where(
|
||||
"propertyEditorAlias = @propertyEditorAlias",
|
||||
new { propertyEditorAlias = propertyType.PropertyEditorAlias })
|
||||
.OrderBy<DataTypeDto>(typeDto => typeDto.NodeId);
|
||||
DataTypeDto? datatype = Database.FirstOrDefault<DataTypeDto>(sql);
|
||||
|
||||
// we cannot assign a data type if one was not found
|
||||
if (datatype != null)
|
||||
{
|
||||
propertyType.DataTypeId = datatype.NodeId;
|
||||
propertyType.DataTypeKey = datatype.NodeDto.UniqueId;
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning(
|
||||
"Could not assign a data type for the property type {PropertyTypeAlias} since no data type was found with a property editor {PropertyEditorAlias}",
|
||||
propertyType.Alias,
|
||||
propertyType.PropertyEditorAlias);
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract TEntity? PerformGet(string alias);
|
||||
|
||||
+27
-7
@@ -56,6 +56,19 @@ internal class ExternalLoginRepository : EntityRepositoryBase<int, IIdentityUser
|
||||
public void DeleteUserLogins(Guid userOrMemberKey) =>
|
||||
Database.Delete<ExternalLoginDto>("WHERE userOrMemberKey=@userOrMemberKey", new { userOrMemberKey });
|
||||
|
||||
/// <inheritdoc />
|
||||
public void DeleteUserLoginsForRemovedProviders(IEnumerable<string> currentLoginProviders)
|
||||
{
|
||||
Sql<ISqlContext> sql = Sql()
|
||||
.Select<ExternalLoginDto>(x => x.Id)
|
||||
.From<ExternalLoginDto>()
|
||||
.Where<ExternalLoginDto>(x => !x.LoginProvider.StartsWith(Constants.Security.MemberExternalAuthenticationTypePrefix)) // Only remove external logins relating to backoffice users, not members.
|
||||
.WhereNotIn<ExternalLoginDto>(x => x.LoginProvider, currentLoginProviders);
|
||||
|
||||
var toDelete = Database.Query<ExternalLoginDto>(sql).Select(x => x.Id).ToList();
|
||||
DeleteExternalLogins(toDelete);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Save(Guid userOrMemberKey, IEnumerable<IExternalLogin> logins)
|
||||
{
|
||||
@@ -94,13 +107,7 @@ internal class ExternalLoginRepository : EntityRepositoryBase<int, IIdentityUser
|
||||
}
|
||||
|
||||
// do the deletes, updates and inserts
|
||||
if (toDelete.Count > 0)
|
||||
{
|
||||
// Before we can remove the external login, we must remove the external login tokens associated with that external login,
|
||||
// otherwise we'll get foreign key constraint errors
|
||||
Database.DeleteMany<ExternalLoginTokenDto>().Where(x => toDelete.Contains(x.ExternalLoginId)).Execute();
|
||||
Database.DeleteMany<ExternalLoginDto>().Where(x => toDelete.Contains(x.Id)).Execute();
|
||||
}
|
||||
DeleteExternalLogins(toDelete);
|
||||
|
||||
foreach (KeyValuePair<int, IExternalLogin> u in toUpdate)
|
||||
{
|
||||
@@ -110,6 +117,19 @@ internal class ExternalLoginRepository : EntityRepositoryBase<int, IIdentityUser
|
||||
Database.InsertBulk(toInsert.Select(i => ExternalLoginFactory.BuildDto(userOrMemberKey, i)));
|
||||
}
|
||||
|
||||
private void DeleteExternalLogins(List<int> externalLoginIds)
|
||||
{
|
||||
if (externalLoginIds.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Before we can remove the external login, we must remove the external login tokens associated with that external login,
|
||||
// otherwise we'll get foreign key constraint errors
|
||||
Database.DeleteMany<ExternalLoginTokenDto>().Where(x => externalLoginIds.Contains(x.ExternalLoginId)).Execute();
|
||||
Database.DeleteMany<ExternalLoginDto>().Where(x => externalLoginIds.Contains(x.Id)).Execute();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Save(Guid userOrMemberKey, IEnumerable<IExternalLoginToken> tokens)
|
||||
{
|
||||
|
||||
+4
-2
@@ -5,6 +5,7 @@ using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Persistence.Querying;
|
||||
using Umbraco.Cms.Core.Persistence.Repositories;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Strings;
|
||||
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
|
||||
using Umbraco.Cms.Infrastructure.Persistence.Querying;
|
||||
@@ -24,8 +25,9 @@ internal class MediaTypeRepository : ContentTypeRepositoryBase<IMediaType>, IMed
|
||||
ILogger<MediaTypeRepository> logger,
|
||||
IContentTypeCommonRepository commonRepository,
|
||||
ILanguageRepository languageRepository,
|
||||
IShortStringHelper shortStringHelper)
|
||||
: base(scopeAccessor, cache, logger, commonRepository, languageRepository, shortStringHelper)
|
||||
IShortStringHelper shortStringHelper,
|
||||
Lazy<IIdKeyMap> idKeyMap)
|
||||
: base(scopeAccessor, cache, logger, commonRepository, languageRepository, shortStringHelper, idKeyMap)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ public class MemberRepository : ContentRepositoryBase<int, IMember, MemberReposi
|
||||
private readonly ITagRepository _tagRepository;
|
||||
private bool _passwordConfigInitialized;
|
||||
private string? _passwordConfigJson;
|
||||
private const string UsernameCacheKey = "uRepo_userNameKey+";
|
||||
|
||||
public MemberRepository(
|
||||
IScopeAccessor scopeAccessor,
|
||||
@@ -229,7 +228,7 @@ public class MemberRepository : ContentRepositoryBase<int, IMember, MemberReposi
|
||||
}
|
||||
|
||||
public IMember? GetByUsername(string? username) =>
|
||||
_memberByUsernameCachePolicy.GetByUserName(UsernameCacheKey, username, PerformGetByUsername, PerformGetAllByUsername);
|
||||
_memberByUsernameCachePolicy.GetByUserName(CacheKeys.MemberUserNameCachePrefix, username, PerformGetByUsername, PerformGetAllByUsername);
|
||||
|
||||
public int[] GetMemberIds(string[] usernames)
|
||||
{
|
||||
@@ -511,7 +510,7 @@ public class MemberRepository : ContentRepositoryBase<int, IMember, MemberReposi
|
||||
|
||||
protected override void PersistDeletedItem(IMember entity)
|
||||
{
|
||||
_memberByUsernameCachePolicy.DeleteByUserName(UsernameCacheKey, entity.Username);
|
||||
_memberByUsernameCachePolicy.DeleteByUserName(CacheKeys.MemberUserNameCachePrefix, entity.Username);
|
||||
base.PersistDeletedItem(entity);
|
||||
}
|
||||
|
||||
@@ -844,10 +843,55 @@ public class MemberRepository : ContentRepositoryBase<int, IMember, MemberReposi
|
||||
|
||||
OnUowRefreshedEntity(new MemberRefreshNotification(entity, new EventMessages()));
|
||||
|
||||
_memberByUsernameCachePolicy.DeleteByUserName(UsernameCacheKey, entity.Username);
|
||||
_memberByUsernameCachePolicy.DeleteByUserName(CacheKeys.MemberUserNameCachePrefix, entity.Username);
|
||||
|
||||
entity.ResetDirtyProperties();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task UpdateLoginPropertiesAsync(IMember member)
|
||||
{
|
||||
var updatedLastLoginDate = member.IsPropertyDirty(nameof(member.LastLoginDate));
|
||||
var updatedSecurityStamp = member.IsPropertyDirty(nameof(member.SecurityStamp));
|
||||
if (updatedLastLoginDate is false && updatedSecurityStamp is false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
NPocoSqlExtensions.SqlUpd<MemberDto> GetMemberSetExpression(IMember member, NPocoSqlExtensions.SqlUpd<MemberDto> m)
|
||||
{
|
||||
var setExpression = new NPocoSqlExtensions.SqlUpd<MemberDto>(SqlContext);
|
||||
if (updatedLastLoginDate)
|
||||
{
|
||||
setExpression.Set(x => x.LastLoginDate, member.LastLoginDate);
|
||||
}
|
||||
|
||||
if (updatedSecurityStamp)
|
||||
{
|
||||
setExpression.Set(x => x.SecurityStampToken, member.SecurityStamp);
|
||||
}
|
||||
|
||||
return setExpression;
|
||||
}
|
||||
|
||||
member.UpdatingEntity();
|
||||
|
||||
Sql<ISqlContext> updateMemberQuery = Sql()
|
||||
.Update<MemberDto>(m => GetMemberSetExpression(member, m))
|
||||
.Where<MemberDto>(m => m.NodeId == member.Id);
|
||||
await Database.ExecuteAsync(updateMemberQuery);
|
||||
|
||||
Sql<ISqlContext> updateContentVersionQuery = Sql()
|
||||
.Update<ContentVersionDto>(m => m.Set(x => x.VersionDate, member.UpdateDate))
|
||||
.Where<ContentVersionDto>(m => m.NodeId == member.Id && m.Current == true);
|
||||
await Database.ExecuteAsync(updateContentVersionQuery);
|
||||
|
||||
OnUowRefreshedEntity(new MemberRefreshNotification(member, new EventMessages()));
|
||||
|
||||
_memberByUsernameCachePolicy.DeleteByUserName(CacheKeys.MemberUserNameCachePrefix, member.Username);
|
||||
|
||||
member.ResetDirtyProperties();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
+5
-3
@@ -5,6 +5,7 @@ using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Persistence.Querying;
|
||||
using Umbraco.Cms.Core.Persistence.Repositories;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Strings;
|
||||
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
|
||||
using Umbraco.Cms.Infrastructure.Persistence.Factories;
|
||||
@@ -27,9 +28,10 @@ internal class MemberTypeRepository : ContentTypeRepositoryBase<IMemberType>, IM
|
||||
ILogger<MemberTypeRepository> logger,
|
||||
IContentTypeCommonRepository commonRepository,
|
||||
ILanguageRepository languageRepository,
|
||||
IShortStringHelper shortStringHelper)
|
||||
: base(scopeAccessor, cache, logger, commonRepository, languageRepository, shortStringHelper) =>
|
||||
_shortStringHelper = shortStringHelper;
|
||||
IShortStringHelper shortStringHelper,
|
||||
Lazy<IIdKeyMap> idKeyMap)
|
||||
: base(scopeAccessor, cache, logger, commonRepository, languageRepository, shortStringHelper, idKeyMap)
|
||||
=> _shortStringHelper = shortStringHelper;
|
||||
|
||||
protected override bool SupportsPublishing => MemberType.SupportsPublishingConst;
|
||||
|
||||
|
||||
@@ -128,10 +128,13 @@ internal class TagRepository : EntityRepositoryBase<int, ITag>, ITagRepository
|
||||
var group = SqlSyntax.GetQuotedColumnName("group");
|
||||
|
||||
// insert tags
|
||||
// - Note we are checking in the subquery for the existence of the tag, so we don't insert duplicates, using a case-insensitive comparison (the
|
||||
// LOWER keyword is consistent across SQLite and SQLServer). This ensures consistent behavior across databases as by default, SQLServer will
|
||||
// perform a case-insensitive comparison, while SQLite will not.
|
||||
var sql1 = $@"INSERT INTO cmsTags (tag, {group}, languageId)
|
||||
SELECT tagSet.tag, tagSet.{group}, tagSet.languageId
|
||||
FROM {tagSetSql}
|
||||
LEFT OUTER JOIN cmsTags ON (tagSet.tag = cmsTags.tag AND tagSet.{group} = cmsTags.{group} AND COALESCE(tagSet.languageId, -1) = COALESCE(cmsTags.languageId, -1))
|
||||
LEFT OUTER JOIN cmsTags ON (LOWER(tagSet.tag) = LOWER(cmsTags.tag) AND LOWER(tagSet.{group}) = LOWER(cmsTags.{group}) AND COALESCE(tagSet.languageId, -1) = COALESCE(cmsTags.languageId, -1))
|
||||
WHERE cmsTags.id IS NULL";
|
||||
|
||||
Database.Execute(sql1);
|
||||
@@ -142,7 +145,7 @@ SELECT {contentId}, {propertyTypeId}, tagSet2.Id
|
||||
FROM (
|
||||
SELECT t.Id
|
||||
FROM {tagSetSql}
|
||||
INNER JOIN cmsTags as t ON (tagSet.tag = t.tag AND tagSet.{group} = t.{group} AND COALESCE(tagSet.languageId, -1) = COALESCE(t.languageId, -1))
|
||||
INNER JOIN cmsTags as t ON (LOWER(tagSet.tag) = LOWER(t.tag) AND LOWER(tagSet.{group}) = LOWER(t.{group}) AND COALESCE(tagSet.languageId, -1) = COALESCE(t.languageId, -1))
|
||||
) AS tagSet2
|
||||
LEFT OUTER JOIN cmsTagRelationship r ON (tagSet2.id = r.tagId AND r.nodeId = {contentId} AND r.propertyTypeID = {propertyTypeId})
|
||||
WHERE r.tagId IS NULL";
|
||||
@@ -245,14 +248,18 @@ WHERE r.tagId IS NULL";
|
||||
{
|
||||
public bool Equals(ITag? x, ITag? y) =>
|
||||
ReferenceEquals(x, y) // takes care of both being null
|
||||
|| (x != null && y != null && x.Text == y.Text && x.Group == y.Group && x.LanguageId == y.LanguageId);
|
||||
|| (x != null &&
|
||||
y != null &&
|
||||
string.Equals(x.Text, y.Text, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(x.Group, y.Group, StringComparison.OrdinalIgnoreCase) &&
|
||||
x.LanguageId == y.LanguageId);
|
||||
|
||||
public int GetHashCode(ITag obj)
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
var h = obj.Text.GetHashCode();
|
||||
h = (h * 397) ^ obj.Group.GetHashCode();
|
||||
var h = StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Text);
|
||||
h = (h * 397) ^ StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Group);
|
||||
h = (h * 397) ^ (obj.LanguageId?.GetHashCode() ?? 0);
|
||||
return h;
|
||||
}
|
||||
|
||||
@@ -372,15 +372,17 @@ SELECT 4 AS [Key], COUNT(id) AS [Value] FROM umbracoUser WHERE userDisabled = 0
|
||||
|
||||
List<int> userIds = dtos.Count == 1 ? new List<int> {dtos[0].Id} : dtos.Select(x => x.Id).ToList();
|
||||
Dictionary<int, UserDto>? xUsers = dtos.Count == 1 ? null : dtos.ToDictionary(x => x.Id, x => x);
|
||||
Sql<ISqlContext> sql;
|
||||
|
||||
// get users2groups
|
||||
|
||||
Sql<ISqlContext> sql = SqlContext.Sql()
|
||||
.Select<User2UserGroupDto>()
|
||||
.From<User2UserGroupDto>()
|
||||
.WhereIn<User2UserGroupDto>(x => x.UserId, userIds);
|
||||
|
||||
List<User2UserGroupDto>? user2Groups = Database.Fetch<User2UserGroupDto>(sql);
|
||||
var user2Groups = Database.FetchByGroups<User2UserGroupDto, int>(userIds, Constants.Sql.MaxParameterCount, ints =>
|
||||
{
|
||||
return SqlContext.Sql()
|
||||
.Select<User2UserGroupDto>()
|
||||
.From<User2UserGroupDto>()
|
||||
.WhereIn<User2UserGroupDto>(x => x.UserId, ints);
|
||||
}).ToList();
|
||||
var groupIds = user2Groups.Select(x => x.UserGroupId).ToList();
|
||||
|
||||
// get groups
|
||||
@@ -422,12 +424,13 @@ SELECT 4 AS [Key], COUNT(id) AS [Value] FROM umbracoUser WHERE userDisabled = 0
|
||||
|
||||
// get start nodes
|
||||
|
||||
sql = SqlContext.Sql()
|
||||
.Select<UserStartNodeDto>()
|
||||
.From<UserStartNodeDto>()
|
||||
.WhereIn<UserStartNodeDto>(x => x.UserId, userIds);
|
||||
|
||||
List<UserStartNodeDto>? startNodes = Database.Fetch<UserStartNodeDto>(sql);
|
||||
var startNodes = Database.FetchByGroups<UserStartNodeDto, int>(userIds, Constants.Sql.MaxParameterCount, ints =>
|
||||
{
|
||||
return SqlContext.Sql()
|
||||
.Select<UserStartNodeDto>()
|
||||
.From<UserStartNodeDto>()
|
||||
.WhereIn<UserStartNodeDto>(x => x.UserId, ints);
|
||||
}).ToList();
|
||||
|
||||
// get groups2languages
|
||||
|
||||
@@ -1053,13 +1056,25 @@ SELECT 4 AS [Key], COUNT(id) AS [Value] FROM umbracoUser WHERE userDisabled = 0
|
||||
return sql;
|
||||
}
|
||||
|
||||
public IEnumerable<IUser> GetNextUsers(int id, int count)
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<IUser> GetNextUsers(int id, int count) => PerformGetNextUsers(id, false, count);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<IUser> GetNextApprovedUsers(int id, int count) => PerformGetNextUsers(id, true, count);
|
||||
|
||||
private IEnumerable<IUser> PerformGetNextUsers(int id, bool approvedOnly, int count)
|
||||
{
|
||||
Sql<ISqlContext> idsQuery = SqlContext.Sql()
|
||||
.Select<UserDto>(x => x.Id)
|
||||
.From<UserDto>()
|
||||
.Where<UserDto>(x => x.Id >= id)
|
||||
.OrderBy<UserDto>(x => x.Id);
|
||||
.Where<UserDto>(x => x.Id >= id);
|
||||
|
||||
if (approvedOnly)
|
||||
{
|
||||
idsQuery = idsQuery.Where<UserDto>(x => x.Disabled == false);
|
||||
}
|
||||
|
||||
idsQuery = idsQuery.OrderBy<UserDto>(x => x.Id);
|
||||
|
||||
// first page is index 1, not zero
|
||||
var ids = Database.Page<int>(1, count, idsQuery).Items.ToArray();
|
||||
@@ -1070,5 +1085,45 @@ SELECT 4 AS [Key], COUNT(id) AS [Value] FROM umbracoUser WHERE userDisabled = 0
|
||||
: GetMany(ids).OrderBy(x => x.Id) ?? Enumerable.Empty<IUser>();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void InvalidateSessionsForRemovedProviders(IEnumerable<string> currentLoginProviders)
|
||||
{
|
||||
// Get all the user keys associated with the removed providers.
|
||||
Sql<ISqlContext> idsQuery = SqlContext.Sql()
|
||||
.Select<ExternalLoginDto>(x => x.UserOrMemberKey)
|
||||
.From<ExternalLoginDto>()
|
||||
.Where<ExternalLoginDto>(x => !x.LoginProvider.StartsWith(Constants.Security.MemberExternalAuthenticationTypePrefix)) // Only invalidate sessions relating to backoffice users, not members.
|
||||
.WhereNotIn<ExternalLoginDto>(x => x.LoginProvider, currentLoginProviders);
|
||||
List<Guid> userKeysAssociatedWithRemovedProviders = Database.Fetch<Guid>(idsQuery);
|
||||
if (userKeysAssociatedWithRemovedProviders.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to user integer IDs.
|
||||
var userIdsAssociatedWithRemovedProviders = userKeysAssociatedWithRemovedProviders
|
||||
.Select(ConvertUserKeyToUserId)
|
||||
.Where(x => x.HasValue)
|
||||
.Select(x => x!.Value)
|
||||
.ToList();
|
||||
if (userIdsAssociatedWithRemovedProviders.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Invalidate the security stamps on the users associated with the removed providers.
|
||||
Sql<ISqlContext> updateQuery = Sql()
|
||||
.Update<UserDto>(u => u.Set(x => x.SecurityStampToken, "0".PadLeft(32, '0')))
|
||||
.WhereIn<UserDto>(x => x.Id, userIdsAssociatedWithRemovedProviders);
|
||||
Database.Execute(updateQuery);
|
||||
}
|
||||
|
||||
private static int? ConvertUserKeyToUserId(Guid userOrMemberKey) =>
|
||||
|
||||
// User Ids are stored as integers in the umbracoUser table, but as a GUID representation
|
||||
// of that integer in umbracoExternalLogin (converted via IntExtensions.ToGuid()).
|
||||
// We need to parse that to get the user Ids to invalidate.
|
||||
IntExtensions.TryParseFromGuid(userOrMemberKey, out int? userId) ? userId : null;
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ internal abstract class BlockEditorPropertyValueEditor : BlockValuePropertyValue
|
||||
{
|
||||
var rawJson = value == null ? string.Empty : value is string str ? str : value.ToString();
|
||||
|
||||
BlockEditorData? blockEditorData = BlockEditorValues.DeserializeAndClean(rawJson);
|
||||
BlockEditorData? blockEditorData = SafeParseBlockEditorData(rawJson);
|
||||
if (blockEditorData is null)
|
||||
{
|
||||
yield break;
|
||||
@@ -115,17 +115,7 @@ internal abstract class BlockEditorPropertyValueEditor : BlockValuePropertyValue
|
||||
{
|
||||
var val = property.GetValue(culture, segment);
|
||||
|
||||
BlockEditorData? blockEditorData;
|
||||
try
|
||||
{
|
||||
blockEditorData = BlockEditorValues.DeserializeAndClean(val);
|
||||
}
|
||||
catch (JsonSerializationException)
|
||||
{
|
||||
// if this occurs it means the data is invalid, shouldn't happen but has happened if we change the data format.
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
BlockEditorData? blockEditorData = SafeParseBlockEditorData(val);
|
||||
if (blockEditorData == null)
|
||||
{
|
||||
return string.Empty;
|
||||
@@ -150,17 +140,7 @@ internal abstract class BlockEditorPropertyValueEditor : BlockValuePropertyValue
|
||||
return null;
|
||||
}
|
||||
|
||||
BlockEditorData? blockEditorData;
|
||||
try
|
||||
{
|
||||
blockEditorData = BlockEditorValues.DeserializeAndClean(editorValue.Value);
|
||||
}
|
||||
catch (JsonSerializationException)
|
||||
{
|
||||
// if this occurs it means the data is invalid, shouldn't happen but has happened if we change the data format.
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
BlockEditorData? blockEditorData = SafeParseBlockEditorData(editorValue.Value);
|
||||
if (blockEditorData == null || blockEditorData.BlockValue.ContentData.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
@@ -171,4 +151,23 @@ internal abstract class BlockEditorPropertyValueEditor : BlockValuePropertyValue
|
||||
// return json
|
||||
return JsonConvert.SerializeObject(blockEditorData.BlockValue, Formatting.None);
|
||||
}
|
||||
|
||||
// We don't throw on error here because we want to be able to parse what we can, even if some of the data is invalid. In cases where migrating
|
||||
// from nested content to blocks, we don't want to trigger a fatal error for retrieving references, as this isn't vital to the operation.
|
||||
// See: https://github.com/umbraco/Umbraco-CMS/issues/19784 and Umbraco support cases.
|
||||
private BlockEditorData? SafeParseBlockEditorData(object? value)
|
||||
{
|
||||
try
|
||||
{
|
||||
return BlockEditorValues.DeserializeAndClean(value);
|
||||
}
|
||||
catch (JsonSerializationException ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Could not deserialize the provided property value into a block editor value: {PropertyValue}. Error: {ErrorMessage}.",
|
||||
value,
|
||||
ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// See LICENSE for more details.
|
||||
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.IO;
|
||||
using Umbraco.Cms.Core.Models.Editors;
|
||||
@@ -16,11 +17,18 @@ namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
/// <summary>
|
||||
/// The value editor for the file upload property editor.
|
||||
/// </summary>
|
||||
internal class FileUploadPropertyValueEditor : DataValueEditor
|
||||
/// <remarks>
|
||||
/// As this class is loaded into <see cref="ValueEditorCache"/> which can be cleared, it needs
|
||||
/// to be disposable in order to properly clean up resources such as
|
||||
/// the settings change subscription and avoid a memory leak.
|
||||
/// </remarks>
|
||||
internal class FileUploadPropertyValueEditor : DataValueEditor, IDisposable
|
||||
{
|
||||
private readonly MediaFileManager _mediaFileManager;
|
||||
private readonly IFileStreamSecurityValidator _fileStreamSecurityValidator;
|
||||
|
||||
private ContentSettings _contentSettings;
|
||||
private readonly IDisposable? _contentSettingsChangeSubscription;
|
||||
|
||||
public FileUploadPropertyValueEditor(
|
||||
DataEditorAttribute attribute,
|
||||
@@ -36,7 +44,7 @@ internal class FileUploadPropertyValueEditor : DataValueEditor
|
||||
_mediaFileManager = mediaFileManager ?? throw new ArgumentNullException(nameof(mediaFileManager));
|
||||
_fileStreamSecurityValidator = fileStreamSecurityValidator;
|
||||
_contentSettings = contentSettings.CurrentValue ?? throw new ArgumentNullException(nameof(contentSettings));
|
||||
contentSettings.OnChange(x => _contentSettings = x);
|
||||
_contentSettingsChangeSubscription = contentSettings.OnChange(x => _contentSettings = x);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -163,4 +171,6 @@ internal class FileUploadPropertyValueEditor : DataValueEditor
|
||||
|
||||
return filepath;
|
||||
}
|
||||
|
||||
public void Dispose() => _contentSettingsChangeSubscription?.Dispose();
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
/// <summary>
|
||||
/// Represents an image cropper property editor.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// As this class is not registered with DI as a singleton, it must be disposed to release
|
||||
/// the settings change subscription and avoid a memory leak.
|
||||
/// </remarks>
|
||||
[DataEditor(
|
||||
Constants.PropertyEditors.Aliases.ImageCropper,
|
||||
"Image Cropper",
|
||||
@@ -42,6 +46,7 @@ public class ImageCropperPropertyEditor : DataEditor, IMediaUrlGenerator,
|
||||
private readonly IIOHelper _ioHelper;
|
||||
private readonly ILogger<ImageCropperPropertyEditor> _logger;
|
||||
private readonly MediaFileManager _mediaFileManager;
|
||||
|
||||
private ContentSettings _contentSettings;
|
||||
|
||||
// Scheduled for removal in v12
|
||||
|
||||
@@ -23,13 +23,20 @@ namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
/// <summary>
|
||||
/// The value editor for the image cropper property editor.
|
||||
/// </summary>
|
||||
internal class ImageCropperPropertyValueEditor : DataValueEditor // TODO: core vs web?
|
||||
/// <remarks>
|
||||
/// As this class is loaded into <see cref="ValueEditorCache"/> which can be cleared, it needs
|
||||
/// to be disposable in order to properly clean up resources such as
|
||||
/// the settings change subscription and avoid a memory leak.
|
||||
/// </remarks>
|
||||
internal class ImageCropperPropertyValueEditor : DataValueEditor, IDisposable
|
||||
{
|
||||
private readonly IDataTypeConfigurationCache _dataTypeConfigurationCache;
|
||||
private readonly IFileStreamSecurityValidator _fileStreamSecurityValidator;
|
||||
private readonly ILogger<ImageCropperPropertyValueEditor> _logger;
|
||||
private readonly MediaFileManager _mediaFileManager;
|
||||
|
||||
private ContentSettings _contentSettings;
|
||||
private readonly IDisposable? _contentSettingsChangeSubscription;
|
||||
|
||||
public ImageCropperPropertyValueEditor(
|
||||
DataEditorAttribute attribute,
|
||||
@@ -49,7 +56,7 @@ internal class ImageCropperPropertyValueEditor : DataValueEditor // TODO: core v
|
||||
_contentSettings = contentSettings.CurrentValue;
|
||||
_dataTypeConfigurationCache = dataTypeConfigurationCache;
|
||||
_fileStreamSecurityValidator = fileStreamSecurityValidator;
|
||||
contentSettings.OnChange(x => _contentSettings = x);
|
||||
_contentSettingsChangeSubscription = contentSettings.OnChange(x => _contentSettings = x);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -252,4 +259,6 @@ internal class ImageCropperPropertyValueEditor : DataValueEditor // TODO: core v
|
||||
|
||||
return filepath;
|
||||
}
|
||||
|
||||
public void Dispose() => _contentSettingsChangeSubscription?.Dispose();
|
||||
}
|
||||
|
||||
@@ -176,6 +176,7 @@ public class RichTextPropertyEditor : DataEditor
|
||||
private readonly IJsonSerializer _jsonSerializer;
|
||||
private readonly IBlockEditorElementTypeCache _elementTypeCache;
|
||||
private readonly IRichTextRequiredValidator _richTextRequiredValidator;
|
||||
private readonly IRichTextRegexValidator _richTextRegexValidator;
|
||||
private readonly ILogger<RichTextPropertyValueEditor> _logger;
|
||||
|
||||
[Obsolete("Use non-obsolete constructor. This is schedules for removal in v16.")]
|
||||
@@ -215,10 +216,11 @@ public class RichTextPropertyEditor : DataEditor
|
||||
elementTypeCache,
|
||||
propertyValidationService,
|
||||
dataValueReferenceFactoryCollection,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IRichTextRequiredValidator>())
|
||||
StaticServiceProvider.Instance.GetRequiredService<IRichTextRequiredValidator>(),
|
||||
StaticServiceProvider.Instance.GetRequiredService<IRichTextRegexValidator>())
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public RichTextPropertyValueEditor(
|
||||
DataEditorAttribute attribute,
|
||||
PropertyEditorCollection propertyEditors,
|
||||
@@ -238,6 +240,49 @@ public class RichTextPropertyEditor : DataEditor
|
||||
IPropertyValidationService propertyValidationService,
|
||||
DataValueReferenceFactoryCollection dataValueReferenceFactoryCollection,
|
||||
IRichTextRequiredValidator richTextRequiredValidator)
|
||||
: this(
|
||||
attribute,
|
||||
propertyEditors,
|
||||
dataTypeReadCache,
|
||||
logger,
|
||||
backOfficeSecurityAccessor,
|
||||
localizedTextService,
|
||||
shortStringHelper,
|
||||
imageSourceParser,
|
||||
localLinkParser,
|
||||
pastedImages,
|
||||
jsonSerializer,
|
||||
ioHelper,
|
||||
htmlSanitizer,
|
||||
macroParameterParser,
|
||||
elementTypeCache,
|
||||
propertyValidationService,
|
||||
dataValueReferenceFactoryCollection,
|
||||
richTextRequiredValidator,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IRichTextRegexValidator>())
|
||||
{
|
||||
}
|
||||
|
||||
public RichTextPropertyValueEditor(
|
||||
DataEditorAttribute attribute,
|
||||
PropertyEditorCollection propertyEditors,
|
||||
IDataTypeConfigurationCache dataTypeReadCache,
|
||||
ILogger<RichTextPropertyValueEditor> logger,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
|
||||
ILocalizedTextService localizedTextService,
|
||||
IShortStringHelper shortStringHelper,
|
||||
HtmlImageSourceParser imageSourceParser,
|
||||
HtmlLocalLinkParser localLinkParser,
|
||||
RichTextEditorPastedImages pastedImages,
|
||||
IJsonSerializer jsonSerializer,
|
||||
IIOHelper ioHelper,
|
||||
IHtmlSanitizer htmlSanitizer,
|
||||
IHtmlMacroParameterParser macroParameterParser,
|
||||
IBlockEditorElementTypeCache elementTypeCache,
|
||||
IPropertyValidationService propertyValidationService,
|
||||
DataValueReferenceFactoryCollection dataValueReferenceFactoryCollection,
|
||||
IRichTextRequiredValidator richTextRequiredValidator,
|
||||
IRichTextRegexValidator richTextRegexValidator)
|
||||
: base(attribute, propertyEditors, dataTypeReadCache, localizedTextService, logger, shortStringHelper, jsonSerializer, ioHelper, dataValueReferenceFactoryCollection)
|
||||
{
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
@@ -249,6 +294,7 @@ public class RichTextPropertyEditor : DataEditor
|
||||
_macroParameterParser = macroParameterParser;
|
||||
_elementTypeCache = elementTypeCache;
|
||||
_richTextRequiredValidator = richTextRequiredValidator;
|
||||
_richTextRegexValidator = richTextRegexValidator;
|
||||
_jsonSerializer = jsonSerializer;
|
||||
_logger = logger;
|
||||
|
||||
@@ -257,6 +303,8 @@ public class RichTextPropertyEditor : DataEditor
|
||||
|
||||
public override IValueRequiredValidator RequiredValidator => _richTextRequiredValidator;
|
||||
|
||||
public override IValueFormatValidator FormatValidator => _richTextRegexValidator;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override object? Configuration
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Extensions;
|
||||
@@ -18,12 +18,20 @@ public static class RichTextPropertyEditorHelper
|
||||
/// <returns>True if the parsing succeeds, false otherwise</returns>
|
||||
/// <remarks>
|
||||
/// The passed value can be:
|
||||
/// - a <see cref="RichTextEditorValue"/> instance (which will be the case if the rich text property is hidden from the editor).
|
||||
/// - a JSON string.
|
||||
/// - a JSON object.
|
||||
/// - a raw markup string (for backwards compatability).
|
||||
/// </remarks>
|
||||
public static bool TryParseRichTextEditorValue(object? value, IJsonSerializer jsonSerializer, ILogger logger, [NotNullWhen(true)] out RichTextEditorValue? richTextEditorValue)
|
||||
{
|
||||
if (value is RichTextEditorValue existingRichTextEditorValue)
|
||||
{
|
||||
// already a RichTextEditorValue instance
|
||||
richTextEditorValue = existingRichTextEditorValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
var stringValue = value as string ?? value?.ToString();
|
||||
if (stringValue is null)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.RegularExpressions;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Blocks;
|
||||
@@ -50,9 +51,11 @@ internal class RichTextPropertyIndexValueFactory : NestedPropertyIndexValueFacto
|
||||
: null;
|
||||
|
||||
// index the stripped HTML values combined with "blocks values resume" value
|
||||
var richTextWithoutMarkup = StripHtmlForIndexing(richTextEditorValue.Markup);
|
||||
|
||||
yield return new KeyValuePair<string, IEnumerable<object?>>(
|
||||
property.Alias,
|
||||
new object[] { $"{richTextEditorValue.Markup.StripHtml()} {blocksIndexValuesResume}" });
|
||||
new object[] { $"{richTextWithoutMarkup} {blocksIndexValuesResume}" });
|
||||
|
||||
// store the raw value
|
||||
yield return new KeyValuePair<string, IEnumerable<object?>>(
|
||||
@@ -75,4 +78,28 @@ internal class RichTextPropertyIndexValueFactory : NestedPropertyIndexValueFacto
|
||||
|
||||
protected override IEnumerable<BlockItemData> GetDataItems(RichTextEditorValue input)
|
||||
=> input.Blocks?.ContentData ?? new List<BlockItemData>();
|
||||
|
||||
/// <summary>
|
||||
/// Strips HTML tags from content while preserving whitespace from line breaks.
|
||||
/// This addresses the issue where <br> tags don't create word boundaries when HTML is stripped.
|
||||
/// </summary>
|
||||
/// <param name="html">The HTML content to strip</param>
|
||||
/// <returns>Plain text with proper word boundaries</returns>
|
||||
private static string StripHtmlForIndexing(string html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// Replace <br> and <br/> tags (with any amount of whitespace and attributes) with spaces
|
||||
// This regex matches:
|
||||
// - <br> (with / without spaces or attributes)
|
||||
// - <br /> (with / without spaces or attributes)
|
||||
html = Regex.Replace(html, @"<br\b[^>]*/?>\s*", " ", RegexOptions.IgnoreCase);
|
||||
|
||||
// Use the existing Microsoft StripHtml function for everything else
|
||||
return html.StripHtml();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -11,10 +11,19 @@ using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
|
||||
internal class UploadFileTypeValidator : IValueValidator
|
||||
/// <summary>
|
||||
/// The value editor for file upload property editors.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// As this class is not registered with DI as a singleton, it must be disposed to release
|
||||
/// the settings change subscription and avoid a memory leak.
|
||||
/// </remarks>
|
||||
internal class UploadFileTypeValidator : IValueValidator, IDisposable
|
||||
{
|
||||
private readonly ILocalizedTextService _localizedTextService;
|
||||
|
||||
private ContentSettings _contentSettings;
|
||||
private readonly IDisposable? _contentSettingsChangeSubscription;
|
||||
|
||||
public UploadFileTypeValidator(
|
||||
ILocalizedTextService localizedTextService,
|
||||
@@ -23,7 +32,7 @@ internal class UploadFileTypeValidator : IValueValidator
|
||||
_localizedTextService = localizedTextService;
|
||||
_contentSettings = contentSettings.CurrentValue;
|
||||
|
||||
contentSettings.OnChange(x => _contentSettings = x);
|
||||
_contentSettingsChangeSubscription = contentSettings.OnChange(x => _contentSettings = x);
|
||||
}
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(object? value, string? valueType, object? dataTypeConfiguration)
|
||||
@@ -103,4 +112,6 @@ internal class UploadFileTypeValidator : IValueValidator
|
||||
extension = fileName.GetFileExtension().TrimStart(".");
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose() => _contentSettingsChangeSubscription?.Dispose();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace Umbraco.Cms.Core.PropertyEditors.Validators;
|
||||
|
||||
internal interface IRichTextRegexValidator : IValueFormatValidator
|
||||
{
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user