Compare commits
72
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 | ||
|
|
e504011b76 | ||
|
|
4d9ea1e2e3 | ||
|
|
6796829908 | ||
|
|
67f7d99696 | ||
|
|
27e3184550 | ||
|
|
c98fd7b219 |
+19
-18
@@ -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.7.1" />
|
||||
<PackageVersion Include="Examine.Core" Version="3.7.1" />
|
||||
<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,8 +74,8 @@
|
||||
<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="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" />
|
||||
@@ -82,11 +83,11 @@
|
||||
<!-- 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>
|
||||
|
||||
+81
-26
@@ -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
|
||||
@@ -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}}))
|
||||
@@ -464,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: |
|
||||
@@ -755,6 +782,34 @@ stages:
|
||||
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
|
||||
###############################################
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,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>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -43,24 +43,41 @@ public static class AppCacheExtensions
|
||||
public static T? GetCacheItem<T>(this IAppCache provider, string cacheKey)
|
||||
{
|
||||
var result = provider.Get(cacheKey);
|
||||
if (IsRetrievedItemNull(result))
|
||||
if (result == null)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
public static T? GetCacheItem<T>(this IAppCache provider, string cacheKey, Func<T> getCacheItem)
|
||||
{
|
||||
var result = provider.Get(cacheKey, () => getCacheItem());
|
||||
if (IsRetrievedItemNull(result))
|
||||
if (result == null)
|
||||
{
|
||||
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 IsRetrievedItemNull(object? result) => result is null or (object)Cms.Core.Constants.Cache.NullRepresentationInCache;
|
||||
private static bool RetrievedNullRepresentationInCache(object result) => result == (object)Cms.Core.Constants.Cache.NullRepresentationInCache;
|
||||
|
||||
private static bool RequestedNullRepresentationInCache<T>() => typeof(T) == typeof(string);
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -346,13 +346,6 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+11
-2
@@ -31,8 +31,12 @@ namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters;
|
||||
/// A value converter for TinyMCE that will ensure any macro content is rendered properly even when
|
||||
/// used dynamically.
|
||||
/// </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>
|
||||
[DefaultPropertyValueConverter]
|
||||
public class RteMacroRenderingValueConverter : SimpleTinyMceValueConverter, IDeliveryApiPropertyValueConverter
|
||||
public class RteMacroRenderingValueConverter : SimpleTinyMceValueConverter, IDeliveryApiPropertyValueConverter, IDisposable
|
||||
{
|
||||
private readonly HtmlImageSourceParser _imageSourceParser;
|
||||
private readonly HtmlLocalLinkParser _linkParser;
|
||||
@@ -47,7 +51,9 @@ public class RteMacroRenderingValueConverter : SimpleTinyMceValueConverter, IDel
|
||||
private readonly ILogger<RteMacroRenderingValueConverter> _logger;
|
||||
private readonly IApiElementBuilder _apiElementBuilder;
|
||||
private readonly RichTextBlockPropertyValueConstructorCache _constructorCache;
|
||||
|
||||
private DeliveryApiSettings _deliveryApiSettings;
|
||||
private readonly IDisposable? _deliveryApiSettingsChangeSubscription;
|
||||
|
||||
[Obsolete("Please use the constructor that takes all arguments. Will be removed in V14.")]
|
||||
public RteMacroRenderingValueConverter(IUmbracoContextAccessor umbracoContextAccessor, IMacroRenderer macroRenderer,
|
||||
@@ -107,8 +113,9 @@ public class RteMacroRenderingValueConverter : SimpleTinyMceValueConverter, IDel
|
||||
_apiElementBuilder = apiElementBuilder;
|
||||
_constructorCache = constructorCache;
|
||||
_logger = logger;
|
||||
|
||||
_deliveryApiSettings = deliveryApiSettingsMonitor.CurrentValue;
|
||||
deliveryApiSettingsMonitor.OnChange(settings => _deliveryApiSettings = settings);
|
||||
_deliveryApiSettingsChangeSubscription = deliveryApiSettingsMonitor.OnChange(settings => _deliveryApiSettings = settings);
|
||||
}
|
||||
|
||||
public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType propertyType) =>
|
||||
@@ -314,4 +321,6 @@ public class RteMacroRenderingValueConverter : SimpleTinyMceValueConverter, IDel
|
||||
|
||||
public required RichTextBlockModel? RichTextBlockModel { get; set; }
|
||||
}
|
||||
|
||||
public void Dispose() => _deliveryApiSettingsChangeSubscription?.Dispose();
|
||||
}
|
||||
|
||||
@@ -153,6 +153,9 @@ public class PublishedContentQuery : IPublishedContentQuery
|
||||
public IEnumerable<IPublishedContent> ContentAtRoot()
|
||||
=> ItemsAtRoot(_publishedSnapshot.Content);
|
||||
|
||||
public IEnumerable<IPublishedContent> ContentAtRoot(string? culture)
|
||||
=> ItemsAtRoot(_publishedSnapshot.Content, culture);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Media
|
||||
@@ -232,8 +235,8 @@ public class PublishedContentQuery : IPublishedContentQuery
|
||||
IPublishedCache? cache)
|
||||
=> cache?.GetByXPath(xpath, vars) ?? Array.Empty<IPublishedContent>();
|
||||
|
||||
private static IEnumerable<IPublishedContent> ItemsAtRoot(IPublishedCache? cache)
|
||||
=> cache?.GetAtRoot() ?? Array.Empty<IPublishedContent>();
|
||||
private static IEnumerable<IPublishedContent> ItemsAtRoot(IPublishedCache? cache, string? culture = null)
|
||||
=> cache?.GetAtRoot(culture) ?? Array.Empty<IPublishedContent>();
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ namespace Umbraco.Cms.Infrastructure.Routing
|
||||
|
||||
using UmbracoContextReference reference = _umbracoContextFactory.EnsureUmbracoContext();
|
||||
IPublishedContentCache? contentCache = reference.UmbracoContext.Content;
|
||||
|
||||
if (contentCache == null)
|
||||
{
|
||||
_logger.LogWarning("Could not track redirects because there is no published content cache available on the current published snapshot.");
|
||||
@@ -106,11 +107,16 @@ namespace Umbraco.Cms.Infrastructure.Routing
|
||||
try
|
||||
{
|
||||
var newRoute = contentCache.GetRouteById(contentId, culture);
|
||||
|
||||
if (!IsValidRoute(newRoute) || oldRoute == newRoute)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure we don't create a self-referencing redirect. This can occur if a document is renamed and then the name is reverted back
|
||||
// to the original. We resolve this by removing any existing redirect that points to the new route.
|
||||
RemoveSelfReferencingRedirect(contentKey, newRoute);
|
||||
|
||||
_redirectUrlService.Register(oldRoute, contentKey, culture);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -121,5 +127,17 @@ namespace Umbraco.Cms.Infrastructure.Routing
|
||||
}
|
||||
|
||||
private static bool IsValidRoute([NotNullWhen(true)] string? route) => route is not null && !route.StartsWith("err/");
|
||||
|
||||
private void RemoveSelfReferencingRedirect(Guid contentKey, string route)
|
||||
{
|
||||
IEnumerable<IRedirectUrl> allRedirectUrls = _redirectUrlService.GetContentRedirectUrls(contentKey);
|
||||
foreach (IRedirectUrl redirectUrl in allRedirectUrls)
|
||||
{
|
||||
if (redirectUrl.Url == route)
|
||||
{
|
||||
_redirectUrlService.Delete(redirectUrl.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,16 +52,16 @@ public sealed class ContentTypeIndexingNotificationHandler : INotificationHandle
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
var changedIds = new Dictionary<string, (List<int> removedIds, List<int> refreshedIds, List<int> otherIds)>();
|
||||
var changedIds = new Dictionary<string, (List<int> removedIds, List<int> refreshedIds)>();
|
||||
|
||||
foreach (ContentTypeCacheRefresher.JsonPayload payload in (ContentTypeCacheRefresher.JsonPayload[])args
|
||||
.MessageObject)
|
||||
{
|
||||
if (!changedIds.TryGetValue(
|
||||
payload.ItemType,
|
||||
out (List<int> removedIds, List<int> refreshedIds, List<int> otherIds) idLists))
|
||||
out (List<int> removedIds, List<int> refreshedIds) idLists))
|
||||
{
|
||||
idLists = (removedIds: new List<int>(), refreshedIds: new List<int>(), otherIds: new List<int>());
|
||||
idLists = (removedIds: new List<int>(), refreshedIds: new List<int>());
|
||||
changedIds.Add(payload.ItemType, idLists);
|
||||
}
|
||||
|
||||
@@ -73,28 +73,24 @@ public sealed class ContentTypeIndexingNotificationHandler : INotificationHandle
|
||||
{
|
||||
idLists.refreshedIds.Add(payload.Id);
|
||||
}
|
||||
else if (payload.ChangeTypes.HasType(ContentTypeChangeTypes.RefreshOther))
|
||||
{
|
||||
idLists.otherIds.Add(payload.Id);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (KeyValuePair<string, (List<int> removedIds, List<int> refreshedIds, List<int> otherIds)> ci in
|
||||
foreach (KeyValuePair<string, (List<int> removedIds, List<int> refreshedIds)> ci in
|
||||
changedIds)
|
||||
{
|
||||
if (ci.Value.refreshedIds.Count > 0 || ci.Value.otherIds.Count > 0)
|
||||
if (ci.Value.refreshedIds.Count > 0)
|
||||
{
|
||||
switch (ci.Key)
|
||||
{
|
||||
case var itemType when itemType == typeof(IContentType).Name:
|
||||
RefreshContentOfContentTypes(ci.Value.refreshedIds.Concat(ci.Value.otherIds).Distinct()
|
||||
RefreshContentOfContentTypes(ci.Value.refreshedIds.Distinct()
|
||||
.ToArray());
|
||||
break;
|
||||
case var itemType when itemType == typeof(IMediaType).Name:
|
||||
RefreshMediaOfMediaTypes(ci.Value.refreshedIds.Concat(ci.Value.otherIds).Distinct().ToArray());
|
||||
RefreshMediaOfMediaTypes(ci.Value.refreshedIds.Distinct().ToArray());
|
||||
break;
|
||||
case var itemType when itemType == typeof(IMemberType).Name:
|
||||
RefreshMemberOfMemberTypes(ci.Value.refreshedIds.Concat(ci.Value.otherIds).Distinct()
|
||||
RefreshMemberOfMemberTypes(ci.Value.refreshedIds.Distinct()
|
||||
.ToArray());
|
||||
break;
|
||||
}
|
||||
@@ -154,6 +150,10 @@ public sealed class ContentTypeIndexingNotificationHandler : INotificationHandle
|
||||
const int pageSize = 500;
|
||||
var page = 0;
|
||||
var total = long.MaxValue;
|
||||
|
||||
// track which Ids have their paths are published
|
||||
var publishChecked = new Dictionary<int, bool>();
|
||||
|
||||
while (page * pageSize < total)
|
||||
{
|
||||
IEnumerable<IContent> contentToRefresh = _contentService.GetPagedOfTypes(
|
||||
@@ -165,20 +165,20 @@ public sealed class ContentTypeIndexingNotificationHandler : INotificationHandle
|
||||
// order by shallowest to deepest, this allows us to check it's published state without checking every item
|
||||
Ordering.By("Path"));
|
||||
|
||||
// track which Ids have their paths are published
|
||||
var publishChecked = new Dictionary<int, bool>();
|
||||
|
||||
foreach (IContent c in contentToRefresh)
|
||||
{
|
||||
var isPublished = false;
|
||||
if (c.Published)
|
||||
{
|
||||
if (!publishChecked.TryGetValue(c.ParentId, out isPublished))
|
||||
if (publishChecked.TryGetValue(c.ParentId, out isPublished) is false)
|
||||
{
|
||||
// nothing by parent id, so query the service and cache the result for the next child to check against
|
||||
isPublished = _contentService.IsPathPublished(c);
|
||||
publishChecked[c.Id] = isPublished;
|
||||
|
||||
// the parent *must* be published if the entire path is published
|
||||
publishChecked[c.ParentId] = isPublished;
|
||||
}
|
||||
|
||||
publishChecked[c.Id] = isPublished;
|
||||
}
|
||||
|
||||
_umbracoIndexingHandler.ReIndexForContent(c, isPublished);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
@@ -17,7 +17,7 @@ internal sealed class DeliveryApiContentIndexingNotificationHandler :
|
||||
{
|
||||
private readonly IDeliveryApiIndexingHandler _deliveryApiIndexingHandler;
|
||||
private readonly ILogger<DeliveryApiContentIndexingNotificationHandler> _logger;
|
||||
private DeliveryApiSettings _deliveryApiSettings;
|
||||
private readonly DeliveryApiSettings _deliveryApiSettings;
|
||||
|
||||
public DeliveryApiContentIndexingNotificationHandler(
|
||||
IDeliveryApiIndexingHandler deliveryApiIndexingHandler,
|
||||
@@ -27,7 +27,6 @@ internal sealed class DeliveryApiContentIndexingNotificationHandler :
|
||||
_deliveryApiIndexingHandler = deliveryApiIndexingHandler;
|
||||
_logger = logger;
|
||||
_deliveryApiSettings = deliveryApiSettings.CurrentValue;
|
||||
deliveryApiSettings.OnChange(settings => _deliveryApiSettings = settings);
|
||||
}
|
||||
|
||||
public void Handle(ContentCacheRefresherNotification notification)
|
||||
|
||||
@@ -129,7 +129,7 @@ public class IdentityMapDefinition : IMapDefinition
|
||||
target.IsApproved = source.IsApproved;
|
||||
target.SecurityStamp = source.SecurityStamp;
|
||||
DateTime? lockedOutUntil = source.LastLockoutDate?.AddMinutes(_securitySettings.UserDefaultLockoutTimeInMinutes);
|
||||
target.LockoutEnd = source.IsLockedOut ? (lockedOutUntil ?? DateTime.MaxValue).ToUniversalTime() : null;
|
||||
target.LockoutEnd = source.IsLockedOut ? lockedOutUntil ?? DateTime.MaxValue : null;
|
||||
}
|
||||
|
||||
// Umbraco.Code.MapAll -Id -LockoutEnabled -PhoneNumber -PhoneNumberConfirmed -ConcurrencyStamp -NormalizedEmail -NormalizedUserName -Roles
|
||||
@@ -146,17 +146,46 @@ public class IdentityMapDefinition : IMapDefinition
|
||||
target.PasswordConfig = source.PasswordConfiguration;
|
||||
target.IsApproved = source.IsApproved;
|
||||
target.SecurityStamp = source.SecurityStamp;
|
||||
DateTime? lockedOutUntil = source.LastLockoutDate?.AddMinutes(_securitySettings.MemberDefaultLockoutTimeInMinutes);
|
||||
target.LockoutEnd = source.IsLockedOut ? (lockedOutUntil ?? DateTime.MaxValue).ToUniversalTime() : null;
|
||||
target.LockoutEnd = GetLockoutEnd(source);
|
||||
target.LastLockoutDateUtc = GetLastLockoutDateUtc(source);
|
||||
target.Comments = source.Comments;
|
||||
target.LastLockoutDateUtc = source.LastLockoutDate == DateTime.MinValue
|
||||
? null
|
||||
: source.LastLockoutDate?.ToUniversalTime();
|
||||
target.CreatedDateUtc = source.CreateDate.ToUniversalTime();
|
||||
target.CreatedDateUtc = EnsureUtcWithServerTime(source.CreateDate);
|
||||
target.Key = source.Key;
|
||||
target.MemberTypeAlias = source.ContentTypeAlias;
|
||||
target.TwoFactorEnabled = _twoFactorLoginService.IsTwoFactorEnabledAsync(source.Key).GetAwaiter().GetResult();
|
||||
|
||||
// NB: same comments re AutoMapper as per BackOfficeUser
|
||||
}
|
||||
|
||||
private DateTimeOffset? GetLockoutEnd(IMember source)
|
||||
{
|
||||
if (source.IsLockedOut is false)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
DateTime? lockedOutUntil = source.LastLockoutDate?.AddMinutes(_securitySettings.MemberDefaultLockoutTimeInMinutes);
|
||||
if (lockedOutUntil.HasValue is false)
|
||||
{
|
||||
return DateTimeOffset.MaxValue;
|
||||
}
|
||||
|
||||
return EnsureUtcWithServerTime(lockedOutUntil.Value);
|
||||
}
|
||||
|
||||
private static DateTime? GetLastLockoutDateUtc(IMember source)
|
||||
{
|
||||
if (source.LastLockoutDate is null || source.LastLockoutDate == DateTime.MinValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return EnsureUtcWithServerTime(source.LastLockoutDate.Value);
|
||||
}
|
||||
|
||||
private static DateTime EnsureUtcWithServerTime(DateTime date) =>
|
||||
|
||||
// We have a server time value here, but the the Kind is UTC, so we can't use .ToUniversalTime() to convert to the UTC
|
||||
// value that the LockoutEnd property expects. We need to create a DateTimeOffset with the correct offset.
|
||||
DateTime.SpecifyKind(date, DateTimeKind.Local).ToUniversalTime();
|
||||
}
|
||||
|
||||
@@ -43,6 +43,10 @@
|
||||
|
||||
<!-- Both Dazinator.Extensions.FileProviders and MiniProfiler.AspNetCore.Mvc bring in a vulnerable version of System.Text.RegularExpressions -->
|
||||
<PackageReference Include="System.Text.RegularExpressions" />
|
||||
|
||||
<!-- Take top-level dependency on these Microsoft.CodeAnalysis.* packages due to conflicts when installing Microsoft.EntityFrameworkCore.Design -->
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -53,9 +53,7 @@ public class PublishedSnapshotServiceEventHandler :
|
||||
|
||||
public void Handle(ContentTypeRefreshedNotification notification)
|
||||
{
|
||||
const ContentTypeChangeTypes types // only for those that have been refreshed
|
||||
= ContentTypeChangeTypes.RefreshMain | ContentTypeChangeTypes.RefreshOther;
|
||||
var contentTypeIds = notification.Changes.Where(x => x.ChangeTypes.HasTypesAny(types)).Select(x => x.Item.Id)
|
||||
var contentTypeIds = notification.Changes.Where(x => x.ChangeTypes.IsStructuralChange()).Select(x => x.Item.Id)
|
||||
.ToArray();
|
||||
if (contentTypeIds.Any())
|
||||
{
|
||||
|
||||
@@ -44,6 +44,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers;
|
||||
[PluginController(Constants.Web.Mvc.BackOfficeApiArea)]
|
||||
[Authorize(Policy = AuthorizationPolicies.TreeAccessDocuments)]
|
||||
[ParameterSwapControllerActionSelector(nameof(GetById), "id", typeof(int), typeof(Guid), typeof(Udi))]
|
||||
[ParameterSwapControllerActionSelector(nameof(GetByIds), "ids", typeof(int[]), typeof(Guid[]), typeof(Udi[]))]
|
||||
[ParameterSwapControllerActionSelector(nameof(GetNiceUrl), "id", typeof(int), typeof(Guid), typeof(Udi))]
|
||||
public class ContentController : ContentControllerBase
|
||||
{
|
||||
@@ -247,6 +248,30 @@ public class ContentController : ContentControllerBase
|
||||
return foundContent.Select(MapToDisplay).WhereNotNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return content for the specified ids
|
||||
/// </summary>
|
||||
/// <param name="ids"></param>
|
||||
/// <returns></returns>
|
||||
[FilterAllowedOutgoingContent(typeof(IEnumerable<ContentItemDisplay>))]
|
||||
public IEnumerable<ContentItemDisplay> GetByIds([FromQuery] Guid[] ids)
|
||||
{
|
||||
IEnumerable<IContent> foundContent = _contentService.GetByIds(ids);
|
||||
return foundContent.Select(MapToDisplay).WhereNotNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return content for the specified ids
|
||||
/// </summary>
|
||||
/// <param name="ids"></param>
|
||||
/// <returns></returns>
|
||||
[FilterAllowedOutgoingContent(typeof(IEnumerable<ContentItemDisplay>))]
|
||||
public IEnumerable<ContentItemDisplay> GetByIds([FromQuery] Udi[] ids)
|
||||
{
|
||||
IEnumerable<IContent> foundContent = _contentService.GetByIds(ids)!;
|
||||
return foundContent.Select(MapToDisplay).WhereNotNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the permissions for a content item for a particular user group
|
||||
/// </summary>
|
||||
|
||||
@@ -674,8 +674,21 @@ public class ContentTypeController : ContentTypeControllerBase<IContentType>
|
||||
[Authorize(Policy = AuthorizationPolicies.TreeAccessDocumentTypes)]
|
||||
public IActionResult Import(string file)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(file))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// The incoming 'file' parameter we expect to contain the just a file name and extension.
|
||||
// We accept only this and no input parameters containing paths, to prevent any path based security exploits.
|
||||
var invalidFileNameChars = Path.GetInvalidFileNameChars();
|
||||
if (file.IndexOfAny(invalidFileNameChars) >= 0 || file.Contains(Path.DirectorySeparatorChar) || file.Contains(Path.AltDirectorySeparatorChar))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var filePath = Path.Combine(_hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempFileUploads), file);
|
||||
if (string.IsNullOrEmpty(file) || !System.IO.File.Exists(filePath))
|
||||
if (System.IO.File.Exists(filePath) is false)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
using System.Xml;
|
||||
using System.Globalization;
|
||||
using System.Net.Mime;
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Hosting;
|
||||
using Umbraco.Cms.Core.Mapping;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.ContentEditing;
|
||||
using Umbraco.Cms.Core.Hosting;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Infrastructure.Packaging;
|
||||
using Umbraco.Cms.Web.Common.Attributes;
|
||||
using Umbraco.Cms.Web.Common.Authorization;
|
||||
using Umbraco.Extensions;
|
||||
using Umbraco.Cms.Infrastructure.Packaging;
|
||||
using System.Xml.Linq;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Umbraco.Cms.Web.BackOffice.Controllers;
|
||||
|
||||
@@ -460,7 +460,17 @@ public class DictionaryController : BackOfficeNotificationsController
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var filePath = Path.Combine(_hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.Data), file);
|
||||
// The incoming 'file' parameter we expect to contain the full path to the uploaded file.
|
||||
// We accept only files coming from the uploads folder to prevent any path based security exploits.
|
||||
var fileName = Path.GetFileName(file);
|
||||
var invalidFileNameChars = Path.GetInvalidFileNameChars();
|
||||
if (fileName.IndexOfAny(invalidFileNameChars) >= 0 || fileName.Contains(Path.DirectorySeparatorChar) || fileName.Contains(Path.AltDirectorySeparatorChar))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var root = _hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempFileUploads);
|
||||
var filePath = Path.Combine(root, fileName);
|
||||
if (!System.IO.File.Exists(filePath))
|
||||
{
|
||||
return NotFound();
|
||||
|
||||
@@ -847,6 +847,21 @@ public class EntityController : UmbracoAuthorizedJsonController
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
EntityBasic? MapEntityBasic(IEntitySlim source, string? culture)
|
||||
{
|
||||
EntityBasic? target = _umbracoMapper.Map<IEntitySlim, EntityBasic>(source, context =>
|
||||
{
|
||||
context.SetCulture(culture);
|
||||
});
|
||||
|
||||
if (target is not null)
|
||||
{
|
||||
target.AdditionalData["hasChildren"] = source.HasChildren;
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
UmbracoObjectTypes? objectType = ConvertToObjectType(type);
|
||||
if (objectType.HasValue)
|
||||
{
|
||||
@@ -857,12 +872,25 @@ public class EntityController : UmbracoAuthorizedJsonController
|
||||
|
||||
var ignoreUserStartNodes = IsDataTypeIgnoringUserStartNodes(dataTypeKey);
|
||||
|
||||
var culture = ClientCulture();
|
||||
|
||||
// root is special: we reduce it to start nodes if the user's start node is not the default, then we need to return their start nodes
|
||||
if (id == Constants.System.Root && startNodeIds.Length > 0 &&
|
||||
startNodeIds.Contains(Constants.System.Root) == false && !ignoreUserStartNodes)
|
||||
startNodeIds.Contains(Constants.System.Root) == false &&
|
||||
ignoreUserStartNodes == false)
|
||||
{
|
||||
return new PagedResult<EntityBasic>(0, 0, 0);
|
||||
var startNodeEntities = _entityService.GetAll(objectType.Value, startNodeIds).ToList();
|
||||
IEnumerable<IEntitySlim> pagedStartNodeEntities = startNodeEntities
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize);
|
||||
return new PagedResult<EntityBasic>(startNodeEntities.Count, pageNumber, pageSize)
|
||||
{
|
||||
Items = pagedStartNodeEntities
|
||||
.Select(source => MapEntityBasic(source, culture))
|
||||
.WhereNotNull(),
|
||||
};
|
||||
}
|
||||
|
||||
//adding multiple conditions ,considering id,key & name as filter param
|
||||
//for id as int
|
||||
int.TryParse(filter, out int filterAsIntId);
|
||||
@@ -882,13 +910,11 @@ public class EntityController : UmbracoAuthorizedJsonController
|
||||
|| x.Key == filterAsGuid),
|
||||
Ordering.By(orderBy, orderDirection));
|
||||
|
||||
|
||||
if (totalRecords == 0)
|
||||
{
|
||||
return new PagedResult<EntityBasic>(0, 0, 0);
|
||||
}
|
||||
|
||||
var culture = ClientCulture();
|
||||
var pagedResult = new PagedResult<EntityBasic>(totalRecords, pageNumber, pageSize)
|
||||
{
|
||||
Items = entities
|
||||
@@ -898,22 +924,8 @@ public class EntityController : UmbracoAuthorizedJsonController
|
||||
(objectType == UmbracoObjectTypes.Document || objectType == UmbracoObjectTypes.Media) is false ||
|
||||
(ContentPermissions.IsInBranchOfStartNode(entity.Path, startNodeIds, startNodePaths, out var hasPathAccess) &&
|
||||
hasPathAccess))
|
||||
.Select(source =>
|
||||
{
|
||||
EntityBasic? target = _umbracoMapper.Map<IEntitySlim, EntityBasic>(source, context =>
|
||||
{
|
||||
context.SetCulture(culture);
|
||||
context.SetCulture(culture);
|
||||
});
|
||||
|
||||
if (target is not null)
|
||||
{
|
||||
//TODO: Why is this here and not in the mapping?
|
||||
target.AdditionalData["hasChildren"] = source.HasChildren;
|
||||
}
|
||||
|
||||
return target;
|
||||
}).WhereNotNull()
|
||||
.Select(source => MapEntityBasic(source, culture))
|
||||
.WhereNotNull(),
|
||||
};
|
||||
|
||||
return pagedResult;
|
||||
@@ -1126,7 +1138,7 @@ public class EntityController : UmbracoAuthorizedJsonController
|
||||
.Select(s => int.Parse(s, CultureInfo.InvariantCulture)).Distinct().ToArray();
|
||||
|
||||
var ignoreUserStartNodes =
|
||||
IsDataTypeIgnoringUserStartNodes(queryStrings?.GetValue<Guid?>("dataTypeId"));
|
||||
IsDataTypeIgnoringUserStartNodes(queryStrings?.GetValue<Guid?>("dataTypeKey"));
|
||||
if (ignoreUserStartNodes == false)
|
||||
{
|
||||
int[]? aids = null;
|
||||
|
||||
@@ -6,7 +6,6 @@ using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Web.Common.Attributes;
|
||||
|
||||
namespace Umbraco.Cms.Web.BackOffice.Controllers;
|
||||
@@ -16,7 +15,8 @@ public class HelpController : UmbracoAuthorizedJsonController
|
||||
{
|
||||
private static HttpClient? _httpClient;
|
||||
private readonly ILogger<HelpController> _logger;
|
||||
private HelpPageSettings? _helpPageSettings;
|
||||
|
||||
private readonly HelpPageSettings? _helpPageSettings;
|
||||
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public HelpController(
|
||||
@@ -25,12 +25,9 @@ public class HelpController : UmbracoAuthorizedJsonController
|
||||
{
|
||||
_logger = logger;
|
||||
|
||||
ResetHelpPageSettings(helpPageSettings.CurrentValue);
|
||||
helpPageSettings.OnChange(ResetHelpPageSettings);
|
||||
_helpPageSettings = helpPageSettings.CurrentValue;
|
||||
}
|
||||
|
||||
private void ResetHelpPageSettings(HelpPageSettings settings) => _helpPageSettings = settings;
|
||||
|
||||
public async Task<List<HelpPage>> GetContextHelpForPage(string section, string tree,
|
||||
string baseUrl = "https://our.umbraco.com")
|
||||
{
|
||||
|
||||
@@ -23,7 +23,7 @@ public class ImagesController : UmbracoAuthorizedApiController
|
||||
{
|
||||
private readonly MediaFileManager _mediaFileManager;
|
||||
private readonly IImageUrlGenerator _imageUrlGenerator;
|
||||
private ContentSettings _contentSettings;
|
||||
private readonly ContentSettings _contentSettings;
|
||||
|
||||
[Obsolete("Use non obsolete-constructor. Scheduled for removal in Umbraco 13.")]
|
||||
public ImagesController(
|
||||
@@ -45,8 +45,6 @@ public class ImagesController : UmbracoAuthorizedApiController
|
||||
_mediaFileManager = mediaFileManager;
|
||||
_imageUrlGenerator = imageUrlGenerator;
|
||||
_contentSettings = contentSettingsMonitor.CurrentValue;
|
||||
|
||||
contentSettingsMonitor.OnChange(x => _contentSettings = x);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -47,6 +47,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers;
|
||||
[PluginController(Constants.Web.Mvc.BackOfficeApiArea)]
|
||||
[Authorize(Policy = AuthorizationPolicies.SectionAccessMedia)]
|
||||
[ParameterSwapControllerActionSelector(nameof(GetById), "id", typeof(int), typeof(Guid), typeof(Udi))]
|
||||
[ParameterSwapControllerActionSelector(nameof(GetByIds), "ids", typeof(int[]), typeof(Guid[]), typeof(Udi[]))]
|
||||
[ParameterSwapControllerActionSelector(nameof(GetChildren), "id", typeof(int), typeof(Guid), typeof(Udi))]
|
||||
public class MediaController : ContentControllerBase
|
||||
{
|
||||
@@ -296,6 +297,30 @@ public class MediaController : ContentControllerBase
|
||||
return foundMedia.Select(media => _umbracoMapper.Map<MediaItemDisplay>(media));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return media for the specified ids
|
||||
/// </summary>
|
||||
/// <param name="ids"></param>
|
||||
/// <returns></returns>
|
||||
[FilterAllowedOutgoingMedia(typeof(IEnumerable<MediaItemDisplay>))]
|
||||
public IEnumerable<MediaItemDisplay?> GetByIds([FromQuery] Guid[] ids)
|
||||
{
|
||||
IEnumerable<IMedia> foundMedia = _mediaService.GetByIds(ids);
|
||||
return foundMedia.Select(media => _umbracoMapper.Map<MediaItemDisplay>(media));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return media for the specified ids
|
||||
/// </summary>
|
||||
/// <param name="ids"></param>
|
||||
/// <returns></returns>
|
||||
[FilterAllowedOutgoingMedia(typeof(IEnumerable<MediaItemDisplay>))]
|
||||
public IEnumerable<MediaItemDisplay?> GetByIds([FromQuery] Udi[] ids)
|
||||
{
|
||||
IEnumerable<IMedia> foundMedia = _mediaService.GetByIds(ids);
|
||||
return foundMedia.Select(media => _umbracoMapper.Map<MediaItemDisplay>(media));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a paged result of media items known to be of a "Folder" type
|
||||
/// </summary>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core.DeliveryApi;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Hosting;
|
||||
using Umbraco.Cms.Core.IO;
|
||||
@@ -21,6 +22,8 @@ using Umbraco.Cms.Web.BackOffice.Security;
|
||||
using Umbraco.Cms.Web.BackOffice.Services;
|
||||
using Umbraco.Cms.Web.BackOffice.SignalR;
|
||||
using Umbraco.Cms.Web.BackOffice.Trees;
|
||||
using Umbraco.Cms.Web.Common.Accessors;
|
||||
using Umbraco.Cms.Web.Common.Rendering;
|
||||
|
||||
namespace Umbraco.Extensions;
|
||||
|
||||
@@ -121,6 +124,9 @@ public static partial class UmbracoBuilderExtensions
|
||||
builder.Services.AddSingleton<UnhandledExceptionLoggerMiddleware>();
|
||||
builder.Services.AddTransient<BlockGridSampleHelper>();
|
||||
builder.Services.AddUnique<IWebhookPresentationFactory, WebhookPresentationFactory>();
|
||||
// deliveryApi will overwrite these more basic ones.
|
||||
builder.Services.AddScoped<IOutputExpansionStrategy, ElementOnlyOutputExpansionStrategy>();
|
||||
builder.Services.AddSingleton<IOutputExpansionStrategyAccessor, RequestContextOutputExpansionStrategyAccessor>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Umbraco.Cms.Core.DeliveryApi;
|
||||
|
||||
namespace Umbraco.Cms.Web.Common.Accessors;
|
||||
|
||||
public sealed class RequestContextOutputExpansionStrategyAccessor : RequestContextServiceAccessorBase<IOutputExpansionStrategy>, IOutputExpansionStrategyAccessor
|
||||
{
|
||||
public RequestContextOutputExpansionStrategyAccessor(IHttpContextAccessor httpContextAccessor)
|
||||
: base(httpContextAccessor)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Umbraco.Cms.Web.Common.Accessors;
|
||||
|
||||
public abstract class RequestContextServiceAccessorBase<T>
|
||||
where T : class
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
protected RequestContextServiceAccessorBase(IHttpContextAccessor httpContextAccessor)
|
||||
=> _httpContextAccessor = httpContextAccessor;
|
||||
|
||||
public bool TryGetValue([NotNullWhen(true)] out T? requestStartNodeService)
|
||||
{
|
||||
requestStartNodeService = _httpContextAccessor.HttpContext?.RequestServices.GetService<T>();
|
||||
return requestStartNodeService is not null;
|
||||
}
|
||||
}
|
||||
@@ -211,7 +211,7 @@ public static class FriendlyPublishedContentExtensions
|
||||
/// </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, string docTypeAlias, string? culture = null)
|
||||
@@ -227,7 +227,7 @@ public static class FriendlyPublishedContentExtensions
|
||||
/// </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,
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
using Umbraco.Cms.Core.DeliveryApi;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Web.Common.Rendering;
|
||||
|
||||
public class ElementOnlyOutputExpansionStrategy : IOutputExpansionStrategy
|
||||
{
|
||||
protected const string All = "$all";
|
||||
protected const string None = "";
|
||||
protected const string ExpandParameterName = "expand";
|
||||
protected const string FieldsParameterName = "fields";
|
||||
|
||||
private readonly IApiPropertyRenderer _propertyRenderer;
|
||||
|
||||
protected Stack<Node?> ExpandProperties { get; } = new();
|
||||
|
||||
protected Stack<Node?> IncludeProperties { get; } = new();
|
||||
|
||||
public ElementOnlyOutputExpansionStrategy(
|
||||
IApiPropertyRenderer propertyRenderer)
|
||||
{
|
||||
_propertyRenderer = propertyRenderer;
|
||||
}
|
||||
|
||||
public virtual 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 virtual 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 virtual IDictionary<string, object?> MapElementProperties(IPublishedElement element)
|
||||
=> MapProperties(element.Properties, true);
|
||||
|
||||
private IDictionary<string, object?> MapProperties(IEnumerable<IPublishedProperty> properties, bool forceExpandProperties = false)
|
||||
{
|
||||
Node? currentExpandProperties = ExpandProperties.Count > 0 ? ExpandProperties.Peek() : null;
|
||||
if (ExpandProperties.Count > 1 && currentExpandProperties is null && forceExpandProperties is false)
|
||||
{
|
||||
return new Dictionary<string, object?>();
|
||||
}
|
||||
|
||||
Node? currentIncludeProperties = IncludeProperties.Count > 0 ? IncludeProperties.Peek() : null;
|
||||
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);
|
||||
|
||||
protected sealed 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,6 @@ public class ConfigureFormOptions : IConfigureOptions<FormOptions>
|
||||
|
||||
// convert from KB to bytes
|
||||
options.MultipartBodyLengthLimit = _runtimeSettings.Value.MaxRequestLength.HasValue
|
||||
? _runtimeSettings.Value.MaxRequestLength.Value * 1024
|
||||
? (long)_runtimeSettings.Value.MaxRequestLength.Value * 1024
|
||||
: long.MaxValue;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,6 @@ public class ConfigureKestrelServerOptions : IConfigureOptions<KestrelServerOpti
|
||||
|
||||
// convert from KB to bytes, 52428800 bytes (50 MB) is the same as in the IIS settings
|
||||
options.Limits.MaxRequestBodySize = _runtimeSettings.Value.MaxRequestLength.HasValue
|
||||
? _runtimeSettings.Value.MaxRequestLength.Value * 1024
|
||||
? (long)_runtimeSettings.Value.MaxRequestLength.Value * 1024
|
||||
: 52428800;
|
||||
}
|
||||
|
||||
@@ -349,6 +349,8 @@ public class UmbracoHelper
|
||||
|
||||
public IEnumerable<IPublishedContent> ContentAtRoot() => _publishedContentQuery.ContentAtRoot();
|
||||
|
||||
public IEnumerable<IPublishedContent> ContentAtRoot(string? culture) => _publishedContentQuery.ContentAtRoot(culture);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Media
|
||||
|
||||
+26
-21
@@ -24,9 +24,9 @@
|
||||
"angular-sanitize": "1.8.3",
|
||||
"angular-touch": "1.8.3",
|
||||
"angular-ui-sortable": "0.19.0",
|
||||
"animejs": "3.2.1",
|
||||
"animejs": "3.2.2",
|
||||
"bootstrap-social": "5.1.1",
|
||||
"chart.js": "^2.9.3",
|
||||
"chart.js": "^2.9.4",
|
||||
"clipboard": "2.0.11",
|
||||
"diff": "5.1.0",
|
||||
"flatpickr": "4.6.13",
|
||||
@@ -37,12 +37,12 @@
|
||||
"lazyload-js": "1.0.0",
|
||||
"moment": "2.29.4",
|
||||
"ng-file-upload": "12.2.13",
|
||||
"nouislider": "15.7.1",
|
||||
"nouislider": "15.7.2",
|
||||
"spectrum-colorpicker2": "2.0.10",
|
||||
"tinymce": "6.8.2",
|
||||
"tinymce": "6.8.6",
|
||||
"typeahead.js": "0.11.1",
|
||||
"underscore": "1.13.6",
|
||||
"wicg-inert": "3.1.2"
|
||||
"underscore": "1.13.7",
|
||||
"wicg-inert": "3.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.21.8",
|
||||
@@ -3360,9 +3360,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/animejs": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/animejs/-/animejs-3.2.1.tgz",
|
||||
"integrity": "sha512-sWno3ugFryK5nhiDm/2BKeFCpZv7vzerWUcUPyAZLDhMek3+S/p418ldZJbJXo5ZUOpfm2kP2XRO4NJcULMy9A=="
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/animejs/-/animejs-3.2.2.tgz",
|
||||
"integrity": "sha512-Ao95qWLpDPXXM+WrmwcKbl6uNlC5tjnowlaRYtuVDHHoygjtIPfDUoK9NthrlZsQSKjZXlmji2TrBUAVbiH0LQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ansi-colors": {
|
||||
"version": "1.1.0",
|
||||
@@ -12971,9 +12972,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nouislider": {
|
||||
"version": "15.7.1",
|
||||
"resolved": "https://registry.npmjs.org/nouislider/-/nouislider-15.7.1.tgz",
|
||||
"integrity": "sha512-5N7C1ru/i8y3dg9+Z6ilj6+m1EfabvOoaRa7ztpxBSKKRZso4vA52DGSbBJjw5XLtFr/LZ9SgGAXqyVtlVHO5w=="
|
||||
"version": "15.7.2",
|
||||
"resolved": "https://registry.npmjs.org/nouislider/-/nouislider-15.7.2.tgz",
|
||||
"integrity": "sha512-Jn1xuJZk0cs7XdzFD2tKucedlvi5/eEAsyewPS70Qqba3a1vcyEey2VZFmp7JiS06QfgkD0kEFk8oMnJrLDWXQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/now-and-later": {
|
||||
"version": "2.0.1",
|
||||
@@ -16694,9 +16696,10 @@
|
||||
"integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q=="
|
||||
},
|
||||
"node_modules/tinymce": {
|
||||
"version": "6.8.2",
|
||||
"resolved": "https://registry.npmjs.org/tinymce/-/tinymce-6.8.2.tgz",
|
||||
"integrity": "sha512-Lho79o2Y1Yn+XdlTEkHTEkEmzwYWTXz7IUsvPwxJF3VTtgHUIAAuBab29kik+f2KED3rZvQavr9D7sHVMJ9x4A=="
|
||||
"version": "6.8.6",
|
||||
"resolved": "https://registry.npmjs.org/tinymce/-/tinymce-6.8.6.tgz",
|
||||
"integrity": "sha512-++XYEs8lKWvZxDCjrr8Baiw7KiikraZ5JkLMg6EdnUVNKJui0IsrAADj5MsyUeFkcEryfn2jd3p09H7REvewyg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/to-absolute-glob": {
|
||||
"version": "2.0.2",
|
||||
@@ -17026,9 +17029,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/underscore": {
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.6.tgz",
|
||||
"integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A=="
|
||||
"version": "1.13.7",
|
||||
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz",
|
||||
"integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undertaker": {
|
||||
"version": "1.3.0",
|
||||
@@ -17738,9 +17742,10 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/wicg-inert": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/wicg-inert/-/wicg-inert-3.1.2.tgz",
|
||||
"integrity": "sha512-Ba9tGNYxXwaqKEi9sJJvPMKuo063umUPsHN0JJsjrs2j8KDSzkWLMZGZ+MH1Jf1Fq4OWZ5HsESJID6nRza2ang=="
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/wicg-inert/-/wicg-inert-3.1.3.tgz",
|
||||
"integrity": "sha512-5L0PKK7iP+0Q/jv2ccgmkz/pfXbumZtlEyWS/xnX+L+Og3f7WjL4+iEs18k4IuldOX3PgGpza3qGndL9xUBjCQ==",
|
||||
"license": "W3C-20150513"
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.4",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9",
|
||||
"node": ">=20.19.4",
|
||||
"npm": ">=10.1"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -36,9 +36,9 @@
|
||||
"angular-sanitize": "1.8.3",
|
||||
"angular-touch": "1.8.3",
|
||||
"angular-ui-sortable": "0.19.0",
|
||||
"animejs": "3.2.1",
|
||||
"animejs": "3.2.2",
|
||||
"bootstrap-social": "5.1.1",
|
||||
"chart.js": "^2.9.3",
|
||||
"chart.js": "^2.9.4",
|
||||
"clipboard": "2.0.11",
|
||||
"diff": "5.1.0",
|
||||
"flatpickr": "4.6.13",
|
||||
@@ -49,12 +49,12 @@
|
||||
"lazyload-js": "1.0.0",
|
||||
"moment": "2.29.4",
|
||||
"ng-file-upload": "12.2.13",
|
||||
"nouislider": "15.7.1",
|
||||
"nouislider": "15.7.2",
|
||||
"spectrum-colorpicker2": "2.0.10",
|
||||
"tinymce": "6.8.2",
|
||||
"tinymce": "6.8.6",
|
||||
"typeahead.js": "0.11.1",
|
||||
"underscore": "1.13.6",
|
||||
"wicg-inert": "3.1.2"
|
||||
"underscore": "1.13.7",
|
||||
"wicg-inert": "3.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.21.8",
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@
|
||||
|
||||
function onAppChanged(activeApp) {
|
||||
// set the name field to readonly if the user don't have update permissions or the active content app is not "Content" or "Info"
|
||||
const allowUpdate = vm.editor.content.allowedActions.includes('A');
|
||||
const allowUpdate = vm.content.allowedActions.includes('A');
|
||||
const isContentBasedApp = activeApp && contentAppHelper.isContentBasedApp(activeApp);
|
||||
vm.nameReadonly = !allowUpdate || !isContentBasedApp;
|
||||
}
|
||||
|
||||
@@ -82,6 +82,9 @@ angular.module("umbraco.directives")
|
||||
//custom initialization for this editor within the grid
|
||||
editor.on('init', function (e) {
|
||||
|
||||
//register global variable to re-save the dragged RTE settings in the controller
|
||||
editor.settings = baseLineConfigObj;
|
||||
|
||||
// Used this init event - as opposed to property init_instance_callback
|
||||
// to turn off the loader
|
||||
scope.isLoading = false;
|
||||
|
||||
@@ -514,10 +514,9 @@ function entityResource($q, $http, umbRequestHelper) {
|
||||
*
|
||||
*/
|
||||
getPagedChildren: function (parentId, type, options) {
|
||||
|
||||
var defaults = {
|
||||
pageSize: 1,
|
||||
pageNumber: 100,
|
||||
pageSize: 100,
|
||||
pageNumber: 1,
|
||||
filter: '',
|
||||
orderDirection: "Ascending",
|
||||
orderBy: "SortOrder",
|
||||
|
||||
@@ -1465,7 +1465,8 @@ function tinyMceService($rootScope, $q, imageHelper, $locale, $http, $timeout, s
|
||||
|
||||
function syncContent() {
|
||||
|
||||
const content = args.editor.getContent();
|
||||
//get the content from tinyMCE and replace Non-breaking space character to HTML Entity
|
||||
const content = args.editor.getContent().replace('\u00A0', ' ');
|
||||
|
||||
if (getPropertyValue() === content) {
|
||||
return;
|
||||
|
||||
@@ -444,4 +444,7 @@ input.umb-panel-header-description {
|
||||
margin: 2px 0 0 0;
|
||||
height: 22px;
|
||||
line-height: 22px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
+91
-46
@@ -130,17 +130,13 @@ angular.module("umbraco")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function onInit() {
|
||||
|
||||
|
||||
localizationService.localizeMany(["defaultdialogs_selectMedia", "mediaPicker_tabClipboard"])
|
||||
.then(function (localizationResult) {
|
||||
setTitle(localizationResult);
|
||||
setNavigation(localizationResult);
|
||||
});
|
||||
|
||||
|
||||
userService.getCurrentUser().then(function (userData) {
|
||||
userStartNodes = userData.startMediaIds;
|
||||
|
||||
@@ -148,10 +144,10 @@ angular.module("umbraco")
|
||||
entityResource.getById($scope.startNodeId, "media")
|
||||
.then(function (ent) {
|
||||
$scope.startNodeId = ent.id;
|
||||
run();
|
||||
return run();
|
||||
});
|
||||
} else {
|
||||
run();
|
||||
return run();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -160,10 +156,11 @@ angular.module("umbraco")
|
||||
//default root item
|
||||
if (!$scope.target) {
|
||||
if ($scope.lastOpenedNode && $scope.lastOpenedNode !== -1) {
|
||||
entityResource.getById($scope.lastOpenedNode, "media")
|
||||
.then(ensureWithinStartNode, gotoStartNode);
|
||||
return entityResource.getById($scope.lastOpenedNode, "media")
|
||||
.then(ensureWithinStartNode)
|
||||
.catch(function () { return gotoStartNode(); });
|
||||
} else {
|
||||
gotoStartNode();
|
||||
return gotoStartNode();
|
||||
}
|
||||
} else {
|
||||
// if a target is specified, go look it up - generally this target will just contain ids not the actual full
|
||||
@@ -176,11 +173,11 @@ angular.module("umbraco")
|
||||
// ID of a UDI or legacy int ID still could be null/undefinied here
|
||||
// As user may dragged in an image that has not been saved to media section yet
|
||||
if (id) {
|
||||
entityResource.getById(id, "Media")
|
||||
return entityResource.getById(id, "Media")
|
||||
.then(function (node) {
|
||||
$scope.target = node;
|
||||
// Moving directly to existing node's folder
|
||||
gotoFolder({ id: node.parentId }).then(function () {
|
||||
// Move directly to existing node's folder, then open details
|
||||
return gotoFolder({ id: node.parentId }).then(function () {
|
||||
selectMedia(node);
|
||||
$scope.target.url = mediaHelper.resolveFileFromEntity(node);
|
||||
$scope.target.thumbnail = mediaHelper.resolveFileFromEntity(node, true);
|
||||
@@ -195,6 +192,7 @@ angular.module("umbraco")
|
||||
// No ID set - then this is going to be a tmpimg that has not been uploaded
|
||||
// User editing this will want to be changing the ALT text
|
||||
openDetailsDialog();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,22 +255,29 @@ angular.module("umbraco")
|
||||
folder = { id: -1, name: "Media", icon: "icon-folder" };
|
||||
}
|
||||
|
||||
if (folder.id > 0) {
|
||||
entityResource.getAncestors(folder.id, "media", null, { dataTypeKey: dataTypeKey })
|
||||
.then(function (anc) {
|
||||
$scope.path = _.filter(anc,
|
||||
function (f) {
|
||||
return f.path.indexOf($scope.startNodeId) !== -1;
|
||||
});
|
||||
folder.path = $scope.path[0].path;
|
||||
performGotoFolder(folder);
|
||||
});
|
||||
} else {
|
||||
$scope.path = [];
|
||||
performGotoFolder(folder);
|
||||
}
|
||||
var setPathPromise = (folder.id > 0)
|
||||
? entityResource.getAncestors(folder.id, "media", null, { dataTypeKey: dataTypeKey })
|
||||
.then(function (anc) {
|
||||
$scope.path = _.filter(anc, function (f) {
|
||||
return f.path.indexOf($scope.startNodeId) !== -1;
|
||||
});
|
||||
if ($scope.path.length > 0) {
|
||||
folder.path = $scope.path[0].path;
|
||||
}
|
||||
})
|
||||
: Promise.resolve().then(function () {
|
||||
$scope.path = [];
|
||||
});
|
||||
|
||||
return getChildren(folder.id);
|
||||
// Chain: resolve path → set current folder → reset paging → fetch page
|
||||
return setPathPromise.then(function () {
|
||||
performGotoFolder(folder);
|
||||
// Reset pagination to the first page on folder change
|
||||
vm.searchOptions.pageNumber = 1;
|
||||
vm.searchOptions.totalItems = 0;
|
||||
vm.searchOptions.totalPages = 0;
|
||||
return getChildren(folder.id);
|
||||
});
|
||||
}
|
||||
|
||||
function performGotoFolder(folder) {
|
||||
@@ -365,7 +370,20 @@ angular.module("umbraco")
|
||||
}
|
||||
|
||||
function onUploadComplete(files) {
|
||||
|
||||
// Refresh the current folder to get updated pagination info.
|
||||
gotoFolder($scope.currentFolder).then(function () {
|
||||
|
||||
// If there are multiple pages, navigate to the last page where new uploads appear.
|
||||
if (vm.searchOptions.totalPages > 1) {
|
||||
vm.searchOptions.pageNumber = vm.searchOptions.totalPages;
|
||||
return getChildren($scope.currentFolder.id);
|
||||
} else {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}).then(function () {
|
||||
|
||||
// Select the newly uploaded items.
|
||||
$timeout(function () {
|
||||
if ($scope.multiPicker) {
|
||||
var images = _.rest(_.sortBy($scope.images, 'id'), $scope.images.length - files.length);
|
||||
@@ -375,6 +393,7 @@ angular.module("umbraco")
|
||||
clickHandler(image);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
@@ -388,11 +407,9 @@ angular.module("umbraco")
|
||||
|
||||
// also make sure the node is not trashed
|
||||
if (nodePath.indexOf($scope.startNodeId.toString()) !== -1 && node.trashed === false) {
|
||||
gotoFolder({ id: $scope.lastOpenedNode || $scope.startNodeId, name: "Media", icon: "icon-folder", path: node.path });
|
||||
return true;
|
||||
return gotoFolder({ id: $scope.lastOpenedNode || $scope.startNodeId, name: "Media", icon: "icon-folder", path: node.path });
|
||||
} else {
|
||||
gotoFolder({ id: $scope.startNodeId, name: "Media", icon: "icon-folder" });
|
||||
return false;
|
||||
return gotoFolder({ id: $scope.startNodeId, name: "Media", icon: "icon-folder" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,7 +425,7 @@ angular.module("umbraco")
|
||||
}
|
||||
|
||||
function gotoStartNode() {
|
||||
gotoFolder({ id: $scope.startNodeId, name: "Media", icon: "icon-folder" });
|
||||
return gotoFolder({ id: $scope.startNodeId, name: "Media", icon: "icon-folder" });
|
||||
}
|
||||
|
||||
function openDetailsDialog() {
|
||||
@@ -484,8 +501,15 @@ angular.module("umbraco")
|
||||
function changePagination(pageNumber) {
|
||||
vm.loading = true;
|
||||
vm.searchOptions.pageNumber = pageNumber;
|
||||
searchMedia();
|
||||
};
|
||||
|
||||
if (vm.searchOptions.filter) {
|
||||
// search pagination (already present)
|
||||
searchMedia();
|
||||
} else {
|
||||
// pagination in folder view
|
||||
getChildren($scope.currentFolder.id);
|
||||
}
|
||||
}
|
||||
|
||||
function searchMedia() {
|
||||
vm.loading = true;
|
||||
@@ -559,22 +583,43 @@ angular.module("umbraco")
|
||||
|
||||
function getChildren(id) {
|
||||
vm.loading = true;
|
||||
return entityResource.getChildren(id, "Media", vm.searchOptions).then(function (data) {
|
||||
return entityResource
|
||||
.getPagedChildren(id, "Media", vm.searchOptions)
|
||||
.then(handlePagedChildren)
|
||||
.finally(function () { vm.loading = false; });
|
||||
}
|
||||
|
||||
var allowedTypes = dialogOptions.filter ? dialogOptions.filter.split(",") : null;
|
||||
function handlePagedChildren(data) {
|
||||
var items = transformItems(data && data.items ? data.items : [], getAllowedTypes());
|
||||
$scope.images = items;
|
||||
syncPagination(vm.searchOptions, data);
|
||||
vm.searchOptions.filter = ""; // reset filter to ensure folder navigation always starts unfiltered
|
||||
preSelectMedia();
|
||||
}
|
||||
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
setDefaultData(data[i]);
|
||||
data[i].filtered = allowedTypes && allowedTypes.indexOf(data[i].metaData.ContentTypeAlias) < 0;
|
||||
}
|
||||
function getAllowedTypes() {
|
||||
return dialogOptions.filter ? dialogOptions.filter.split(",") : null;
|
||||
}
|
||||
|
||||
vm.searchOptions.filter = "";
|
||||
$scope.images = data ? data : [];
|
||||
function transformItems(items, allowedTypes) {
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
setDefaultData(items[i]);
|
||||
items[i].filtered = !!(allowedTypes && allowedTypes.indexOf(items[i].metaData.ContentTypeAlias) < 0);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// set already selected medias to selected
|
||||
preSelectMedia();
|
||||
vm.loading = false;
|
||||
});
|
||||
function syncPagination(opts, data) {
|
||||
var d = data || {};
|
||||
if (d.pageNumber > 0) {
|
||||
opts.pageNumber = d.pageNumber;
|
||||
}
|
||||
opts.pageNumber = d.pageNumber;
|
||||
if (d.pageSize > 0) {
|
||||
opts.pageSize = d.pageSize;
|
||||
}
|
||||
opts.totalItems = d.totalItems;
|
||||
opts.totalPages = d.totalPages;
|
||||
}
|
||||
|
||||
function setDefaultData(item) {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center" style="flex: 1;">
|
||||
<div class="flex items-center" style="flex: 1; width: 100%;">
|
||||
|
||||
<ng-form data-element="editor-icon" name="iconForm">
|
||||
<button
|
||||
@@ -25,7 +25,7 @@
|
||||
</button>
|
||||
</ng-form>
|
||||
|
||||
<div id="nameField" class="umb-editor-header__name-and-description" style="flex: 1 1 auto;">
|
||||
<div id="nameField" class="umb-editor-header__name-and-description" style="flex: 1; width: calc(100% - 75px);">
|
||||
<div>
|
||||
<p tabindex="0" class="sr-only" ng-show="accessibility.a11yMessageVisible">
|
||||
{{accessibility.a11yMessage}}
|
||||
@@ -78,7 +78,7 @@
|
||||
ng-if="!hideDescription && !descriptionLocked"
|
||||
ng-model="$parent.description"/>
|
||||
|
||||
<p class="umb-panel-header-locked-description" id="editor-description-{{$id}}" ng-if="descriptionLocked">{{ description }}</p>
|
||||
<p class="umb-panel-header-locked-description" id="editor-description-{{$id}}" ng-if="descriptionLocked" title="{{description}}">{{ description }}</p>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -527,7 +527,7 @@ ng-form.ng-invalid > .umb-block-grid__block:not(.--active) > .umb-block-grid__bl
|
||||
&[disabled]:hover {
|
||||
color: @gray-7;
|
||||
border-color: @gray-7;
|
||||
cursor: default;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.umb-block-grid__create-button {
|
||||
|
||||
+3
-3
@@ -65,12 +65,12 @@
|
||||
ng-click="vm.blockEditorApi.requestShowCreate(vm.parentBlock, vm.areaKey, vm.entries.length, $event)">
|
||||
</uui-button-inline-create>
|
||||
|
||||
<div class="umb-block-grid__actions" ng-if="!vm.blockEditorApi.readonly && vm.depth === '0'">
|
||||
<div class="umb-block-grid__actions" ng-if="vm.depth === '0'">
|
||||
<button
|
||||
id="{{vm.model.alias}}"
|
||||
type="button"
|
||||
class="btn-reset umb-block-grid__create-button umb-outline"
|
||||
ng-disabled="vm.locallyAvailableBlockTypes.length === 0"
|
||||
ng-disabled="vm.locallyAvailableBlockTypes.length === 0 || vm.blockEditorApi.readonly"
|
||||
ng-click="vm.blockEditorApi.requestShowCreate(vm.parentBlock, vm.areaKey, vm.entries.length, $event)">
|
||||
<span ng-if="vm.createLabel">{{vm.createLabel}}</span>
|
||||
<localize ng-if="!vm.createLabel && vm.locallyAvailableBlockTypes.length !== 1" key="blockEditor_addBlock">Add content</localize>
|
||||
@@ -79,7 +79,7 @@
|
||||
<button type="button"
|
||||
class="btn-reset umb-block-grid__clipboard-button umb-outline"
|
||||
ng-class="{'--jump': vm.jumpClipboardButton}"
|
||||
ng-disabled="vm.clipboardItems.length === 0"
|
||||
ng-disabled="vm.clipboardItems.length === 0 || vm.blockEditorApi.readonly"
|
||||
ng-click="vm.blockEditorApi.requestShowClipboard(vm.parentBlock, vm.areaKey, vm.entries.length, $event)"
|
||||
localize="title"
|
||||
title="@blockEditor_tabClipboard">
|
||||
|
||||
+6
-2
@@ -83,7 +83,6 @@
|
||||
var shadowRoot;
|
||||
var firstLayoutContainer;
|
||||
|
||||
|
||||
var vm = this;
|
||||
|
||||
vm.readonly = false;
|
||||
@@ -96,6 +95,10 @@
|
||||
if (deleteAllBlocksAction) {
|
||||
deleteAllBlocksAction.isDisabled = vm.readonly;
|
||||
}
|
||||
|
||||
if (enterSortModeAction) {
|
||||
enterSortModeAction.isDisabled = vm.readonly;
|
||||
}
|
||||
});
|
||||
|
||||
vm.loading = true;
|
||||
@@ -1367,8 +1370,9 @@
|
||||
if (copyAllBlocksAction) {
|
||||
copyAllBlocksAction.isDisabled = vm.layout.length === 0;
|
||||
}
|
||||
|
||||
if (deleteAllBlocksAction) {
|
||||
deleteAllBlocksAction.isDisabled = vm.layout.length === 0;
|
||||
deleteAllBlocksAction.isDisabled = vm.layout.length === 0 || vm.readonly;
|
||||
}
|
||||
|
||||
// validate limits:
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@
|
||||
var shadowRoot = $element[0].attachShadow({ mode: 'open' });
|
||||
shadowRoot.innerHTML = `
|
||||
<style>
|
||||
@import "${model.stylesheet}"
|
||||
@import "${model.stylesheet}?umb__rnd=${Umbraco.Sys.ServerVariables.application.cacheBuster}"
|
||||
</style>
|
||||
<div class="umb-block-list__block--view" ng-include="'${model.view}'"></div>
|
||||
`;
|
||||
|
||||
+4
@@ -31,6 +31,10 @@ angular.module("umbraco").controller("Umbraco.PropertyEditors.CheckboxListContro
|
||||
if ($scope.model.value === null || $scope.model.value === undefined) {
|
||||
$scope.model.value = [];
|
||||
}
|
||||
|
||||
// ensure any values that are not in the config are removed from the model.value (i.e. if the config has changed since the value was saved).
|
||||
var validValues = vm.configItems.map(item => item.value);
|
||||
$scope.model.value = $scope.model.value.filter(val => validValues.includes(val));
|
||||
|
||||
// update view model.
|
||||
generateViewModel($scope.model.value);
|
||||
|
||||
+14
-8
@@ -1,4 +1,3 @@
|
||||
|
||||
/**
|
||||
* The controller that is used for a couple different Property Editors: Multi Node Tree Picker, Content Picker,
|
||||
* since this is used by MNTP and it supports content, media and members, there is code to deal with all 3 of those types
|
||||
@@ -345,17 +344,24 @@ function contentPickerController($scope, $q, $routeParams, $location, entityReso
|
||||
|
||||
};
|
||||
|
||||
$scope.remove = function (index) {
|
||||
$scope.remove = function (id) {
|
||||
if (!$scope.allowRemove) return;
|
||||
|
||||
var currIds = $scope.model.value ? $scope.model.value.split(',') : [];
|
||||
if (currIds.length > 0) {
|
||||
currIds.splice(index, 1);
|
||||
setDirty();
|
||||
$scope.model.value = currIds.join();
|
||||
var currUdis = $scope.model.value ? $scope.model.value.split(',') : [];
|
||||
if (currUdis.length > 0) {
|
||||
|
||||
// Remove the node with the provided UDI.
|
||||
var index = currUdis.indexOf(id.toString()); // id may be an integer ID or a UDI string, split() always returns strings.
|
||||
if (index >= 0) {
|
||||
currUdis.splice(index, 1);
|
||||
setDirty();
|
||||
|
||||
// If no ids left, set value to null to match `clear()` behavior.
|
||||
$scope.model.value = currUdis.length > 0 ? currUdis.join() : null;
|
||||
}
|
||||
}
|
||||
|
||||
removeAllEntriesAction.isDisabled = currIds.length === 0;
|
||||
removeAllEntriesAction.isDisabled = currUdis.length === 0;
|
||||
};
|
||||
|
||||
$scope.showNode = function (index) {
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@
|
||||
description="node.url"
|
||||
sortable="!sortableOptions.disabled"
|
||||
allow-remove="allowRemove"
|
||||
allow-open="model.config.showOpenButton && allowOpen && !dialogEditor && !readonly"
|
||||
on-remove="remove($index)"
|
||||
allow-open="model.config.showOpenButton && allowOpen && !dialogEditor"
|
||||
on-remove="remove(model.config.idType === 'udi' ? node.udi : node.id)"
|
||||
on-open="openEditor(node)">
|
||||
</umb-node-preview>
|
||||
</div>
|
||||
|
||||
@@ -75,7 +75,7 @@ angular.module("umbraco")
|
||||
ui.item.find(".umb-rte").each(function (key, value) {
|
||||
// remove all RTEs in the dragged row and save their settings
|
||||
var rteId = value.id;
|
||||
var editor = _.findWhere(tinyMCE.editors, { id: rteId });
|
||||
var editor = _.findWhere(tinyMCE.get() , { id: rteId });
|
||||
if (editor) {
|
||||
draggedRteSettings[rteId] = editor.settings;
|
||||
}
|
||||
@@ -92,7 +92,7 @@ angular.module("umbraco")
|
||||
var rteId = value.id;
|
||||
var settings = draggedRteSettings[rteId];
|
||||
if (!settings) {
|
||||
var editor = _.findWhere(tinyMCE.editors, { id: rteId });
|
||||
var editor = _.findWhere(tinyMCE.get() , { id: rteId });
|
||||
if (editor) {
|
||||
settings = editor.settings;
|
||||
}
|
||||
@@ -189,7 +189,7 @@ angular.module("umbraco")
|
||||
if ($.inArray(rteId, notIncludedRte) < 0) {
|
||||
|
||||
// remember this RTEs settings, cause we need to update it later.
|
||||
var editor = _.findWhere(tinyMCE.editors, { id: rteId })
|
||||
var editor = _.findWhere(tinyMCE.get(), { id: rteId })
|
||||
if (editor) {
|
||||
draggedRteSettings[rteId] = editor.settings;
|
||||
}
|
||||
@@ -203,9 +203,8 @@ angular.module("umbraco")
|
||||
var rteId = $(this).attr("id");
|
||||
|
||||
if ($.inArray(rteId, notIncludedRte) < 0) {
|
||||
|
||||
// remember this RTEs settings, cause we need to update it later.
|
||||
var editor = _.findWhere(tinyMCE.editors, { id: rteId })
|
||||
var editor = _.findWhere(tinyMCE.get() , { id: rteId })
|
||||
if (editor) {
|
||||
draggedRteSettings[rteId] = editor.settings;
|
||||
}
|
||||
@@ -236,7 +235,7 @@ angular.module("umbraco")
|
||||
var rteId = value.id;
|
||||
|
||||
// remember this RTEs settings, cause we need to update it later.
|
||||
var editor = _.findWhere(tinyMCE.editors, { id: rteId });
|
||||
var editor = _.findWhere(tinyMCE.get() , { id: rteId });
|
||||
|
||||
// save the dragged RTE settings
|
||||
if (editor) {
|
||||
@@ -258,7 +257,7 @@ angular.module("umbraco")
|
||||
var rteId = value.id;
|
||||
if ($.inArray(rteId, notIncludedRte) < 0) {
|
||||
|
||||
var editor = _.findWhere(tinyMCE.editors, { id: rteId });
|
||||
var editor = _.findWhere(tinyMCE.get() , { id: rteId });
|
||||
if (editor) {
|
||||
draggedRteSettings[rteId] = editor.settings;
|
||||
}
|
||||
|
||||
@@ -276,15 +276,16 @@
|
||||
|
||||
};
|
||||
|
||||
Utilities.extend(baseLineConfigObj, standardConfig);
|
||||
|
||||
// Readonly mode
|
||||
baseLineConfigObj.toolbar = vm.readonly ? false : baseLineConfigObj.toolbar;
|
||||
baseLineConfigObj.readonly = vm.readonly ? true : baseLineConfigObj.readonly;
|
||||
const options = Utilities.extend({}, baseLineConfigObj, standardConfig);
|
||||
|
||||
// We need to wait for DOM to have rendered before we can find the element by ID.
|
||||
$timeout(function () {
|
||||
tinymce.init(baseLineConfigObj);
|
||||
|
||||
// Readonly mode - ensure observe of readonly attribute has triggered, see https://github.com/umbraco/Umbraco-CMS/issues/20952
|
||||
options.toolbar = vm.readonly ? false : options.toolbar;
|
||||
options.readonly = vm.readonly ? true : options.readonly;
|
||||
|
||||
tinymce.init(options);
|
||||
}, 50);
|
||||
|
||||
//listen for formSubmitting event (the result is callback used to remove the event subscription)
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ describe('Content picker controller tests', function () {
|
||||
});
|
||||
|
||||
it("Removing an item should update renderModel, ids and model.value", function(){
|
||||
scope.remove(1);
|
||||
scope.remove(1231);
|
||||
scope.$apply();
|
||||
expect(scope.renderModel.length).toBe(2);
|
||||
expect(scope.model.value).toBe("1233,23121");
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<RootNamespace>Umbraco.Cms.Web.UI</RootNamespace>
|
||||
<IsPackable>false</IsPackable>
|
||||
<EnablePackageValidation>false</EnablePackageValidation>
|
||||
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="..\Umbraco.Cms.Targets\buildTransitive\Umbraco.Cms.Targets.props" />
|
||||
@@ -13,13 +14,13 @@
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Add design/build time support for EF Core migrations -->
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" Version="8.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Ensure the AppLocalIcu setting is the same as the referenced ICU package version and changes are also done to the template project! -->
|
||||
<!-- Opt-in to app-local ICU to ensure consistent globalization APIs across different platforms -->
|
||||
<PackageReference Include="Microsoft.ICU.ICU4C.Runtime" VersionOverride="72.1.0.3" />
|
||||
<PackageReference Include="Microsoft.ICU.ICU4C.Runtime" Version="72.1.0.3" />
|
||||
<RuntimeHostConfigurationOption Include="System.Globalization.AppLocalIcu" Value="72.1.0.3" Condition="$(RuntimeIdentifier.StartsWith('linux')) or $(RuntimeIdentifier.StartsWith('win')) or ('$(RuntimeIdentifier)' == '' and !$([MSBuild]::IsOSPlatform('osx')))" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -51,8 +51,8 @@ public class UmbLoginStatusController : SurfaceController
|
||||
|
||||
TempData["LogoutSuccess"] = true;
|
||||
|
||||
// If there is a specified path to redirect to then use it.
|
||||
if (model.RedirectUrl.IsNullOrWhiteSpace() == false)
|
||||
// If there is a specified path to redirect to and it is validated as a local URL, then use it.
|
||||
if (model.RedirectUrl.IsNullOrWhiteSpace() is false && Url.IsLocalUrl(model.RedirectUrl!))
|
||||
{
|
||||
return Redirect(model.RedirectUrl!);
|
||||
}
|
||||
|
||||
@@ -70,8 +70,8 @@ public class UmbProfileController : SurfaceController
|
||||
|
||||
TempData["FormSuccess"] = true;
|
||||
|
||||
// If there is a specified path to redirect to then use it.
|
||||
if (model.RedirectUrl.IsNullOrWhiteSpace() == false)
|
||||
// If there is a specified path to redirect to and it is validated as a local URL, then use it.
|
||||
if (model.RedirectUrl.IsNullOrWhiteSpace() is false && Url.IsLocalUrl(model.RedirectUrl!))
|
||||
{
|
||||
return Redirect(model.RedirectUrl!);
|
||||
}
|
||||
|
||||
@@ -59,8 +59,8 @@ public class UmbRegisterController : SurfaceController
|
||||
{
|
||||
TempData["FormSuccess"] = true;
|
||||
|
||||
// If there is a specified path to redirect to then use it.
|
||||
if (model.RedirectUrl.IsNullOrWhiteSpace() == false)
|
||||
// If there is a specified path to redirect to and it is validated as a local URL, then use it.
|
||||
if (model.RedirectUrl.IsNullOrWhiteSpace() is false && Url.IsLocalUrl(model.RedirectUrl!))
|
||||
{
|
||||
return Redirect(model.RedirectUrl!);
|
||||
}
|
||||
|
||||
+142
-2
@@ -1,12 +1,15 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Persistence.Repositories;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement;
|
||||
using Umbraco.Cms.Infrastructure.Scoping;
|
||||
using Umbraco.Cms.Tests.Common.Testing;
|
||||
using Umbraco.Cms.Tests.Integration.Testing;
|
||||
|
||||
@@ -21,6 +24,15 @@ public class DictionaryRepositoryTest : UmbracoIntegrationTest
|
||||
|
||||
private IDictionaryRepository CreateRepository() => GetRequiredService<IDictionaryRepository>();
|
||||
|
||||
private IDictionaryRepository CreateRepositoryWithCache(AppCaches cache) =>
|
||||
|
||||
// Create a repository with a real runtime cache.
|
||||
new DictionaryRepository(
|
||||
GetRequiredService<IScopeAccessor>(),
|
||||
cache,
|
||||
GetRequiredService<ILogger<DictionaryRepository>>(),
|
||||
GetRequiredService<ILoggerFactory>());
|
||||
|
||||
[Test]
|
||||
public void Can_Perform_Get_By_Key_On_DictionaryRepository()
|
||||
{
|
||||
@@ -395,6 +407,134 @@ public class DictionaryRepositoryTest : UmbracoIntegrationTest
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Can_Perform_Cached_Request_For_Existing_Value_By_Key_On_DictionaryRepository_With_Cache()
|
||||
{
|
||||
var cache = AppCaches.Create(Mock.Of<IRequestCache>());
|
||||
var repository = CreateRepositoryWithCache(cache);
|
||||
|
||||
using (ScopeProvider.CreateScope())
|
||||
{
|
||||
var dictionaryItem = repository.Get("Read More");
|
||||
|
||||
Assert.AreEqual("Read More", dictionaryItem.Translations.Single(x => x.LanguageIsoCode == "en-US").Value);
|
||||
}
|
||||
|
||||
// Modify the value directly in the database. This won't be reflected in the repository cache and hence if the cache
|
||||
// is working as expected we should get the same value as above.
|
||||
using (var scope = ScopeProvider.CreateScope())
|
||||
{
|
||||
scope.Database.Execute("UPDATE cmsLanguageText SET value = 'Read More (updated)' WHERE value = 'Read More' and LanguageId = 1");
|
||||
scope.Complete();
|
||||
}
|
||||
|
||||
using (ScopeProvider.CreateScope())
|
||||
{
|
||||
var dictionaryItem = repository.Get("Read More");
|
||||
|
||||
Assert.AreEqual("Read More", dictionaryItem.Translations.Single(x => x.LanguageIsoCode == "en-US").Value);
|
||||
}
|
||||
|
||||
cache.IsolatedCaches.ClearCache<IDictionaryItem>();
|
||||
using (ScopeProvider.CreateScope())
|
||||
{
|
||||
var dictionaryItem = repository.Get("Read More");
|
||||
|
||||
Assert.AreEqual("Read More (updated)", dictionaryItem.Translations.Single(x => x.LanguageIsoCode == "en-US").Value);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Can_Perform_Cached_Request_For_NonExisting_Value_By_Key_On_DictionaryRepository_With_Cache()
|
||||
{
|
||||
var cache = AppCaches.Create(Mock.Of<IRequestCache>());
|
||||
var repository = CreateRepositoryWithCache(cache);
|
||||
|
||||
using (ScopeProvider.CreateScope())
|
||||
{
|
||||
var dictionaryItem = repository.Get("Read More Updated");
|
||||
|
||||
Assert.IsNull(dictionaryItem);
|
||||
}
|
||||
|
||||
// Modify the value directly in the database such that it now exists. This won't be reflected in the repository cache and hence if the cache
|
||||
// is working as expected we should get the same null value as above.
|
||||
using (var scope = ScopeProvider.CreateScope())
|
||||
{
|
||||
scope.Database.Execute("UPDATE cmsDictionary SET [key] = 'Read More Updated' WHERE [key] = 'Read More'");
|
||||
scope.Complete();
|
||||
}
|
||||
|
||||
using (ScopeProvider.CreateScope())
|
||||
{
|
||||
var dictionaryItem = repository.Get("Read More Updated");
|
||||
|
||||
Assert.IsNull(dictionaryItem);
|
||||
}
|
||||
|
||||
cache.IsolatedCaches.ClearCache<IDictionaryItem>();
|
||||
using (ScopeProvider.CreateScope())
|
||||
{
|
||||
var dictionaryItem = repository.Get("Read More Updated");
|
||||
|
||||
Assert.IsNotNull(dictionaryItem);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Cannot_Perform_Cached_Request_For_Existing_Value_By_Key_On_DictionaryRepository_Without_Cache()
|
||||
{
|
||||
var repository = CreateRepository();
|
||||
|
||||
using (ScopeProvider.CreateScope())
|
||||
{
|
||||
var dictionaryItem = repository.Get("Read More");
|
||||
|
||||
Assert.AreEqual("Read More", dictionaryItem.Translations.Single(x => x.LanguageIsoCode == "en-US").Value);
|
||||
}
|
||||
|
||||
// Modify the value directly in the database. As we don't have caching enabled on the repository we should get the new value.
|
||||
using (var scope = ScopeProvider.CreateScope())
|
||||
{
|
||||
scope.Database.Execute("UPDATE cmsLanguageText SET value = 'Read More (updated)' WHERE value = 'Read More' and LanguageId = 1");
|
||||
scope.Complete();
|
||||
}
|
||||
|
||||
using (ScopeProvider.CreateScope())
|
||||
{
|
||||
var dictionaryItem = repository.Get("Read More");
|
||||
|
||||
Assert.AreEqual("Read More (updated)", dictionaryItem.Translations.Single(x => x.LanguageIsoCode == "en-US").Value);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Cannot_Perform_Cached_Request_For_NonExisting_Value_By_Key_On_DictionaryRepository_Without_Cache()
|
||||
{
|
||||
var repository = CreateRepository();
|
||||
|
||||
using (ScopeProvider.CreateScope())
|
||||
{
|
||||
var dictionaryItem = repository.Get("Read More Updated");
|
||||
|
||||
Assert.IsNull(dictionaryItem);
|
||||
}
|
||||
|
||||
// Modify the value directly in the database such that it now exists. As we don't have caching enabled on the repository we should get the new value.
|
||||
using (var scope = ScopeProvider.CreateScope())
|
||||
{
|
||||
scope.Database.Execute("UPDATE cmsDictionary SET [key] = 'Read More Updated' WHERE [key] = 'Read More'");
|
||||
scope.Complete();
|
||||
}
|
||||
|
||||
using (ScopeProvider.CreateScope())
|
||||
{
|
||||
var dictionaryItem = repository.Get("Read More Updated");
|
||||
|
||||
Assert.IsNotNull(dictionaryItem);
|
||||
}
|
||||
}
|
||||
|
||||
public void CreateTestData()
|
||||
{
|
||||
var localizationService = GetRequiredService<ILocalizationService>();
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
using Umbraco.Cms.Core.Routing;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Web;
|
||||
using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement;
|
||||
using Umbraco.Cms.Infrastructure.Routing;
|
||||
using Umbraco.Cms.Infrastructure.Scoping;
|
||||
using Umbraco.Cms.Tests.Common.Testing;
|
||||
using Umbraco.Cms.Tests.Integration.Testing;
|
||||
|
||||
namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Routing;
|
||||
|
||||
[TestFixture]
|
||||
[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)]
|
||||
public class RedirectTrackerTests : UmbracoIntegrationTestWithContent
|
||||
{
|
||||
private IRedirectUrlService RedirectUrlService => GetRequiredService<IRedirectUrlService>();
|
||||
|
||||
private IContent _rootPage;
|
||||
private IContent _testPage;
|
||||
|
||||
public override void CreateTestData()
|
||||
{
|
||||
base.CreateTestData();
|
||||
|
||||
var rootContent = ContentService.GetRootContent().First();
|
||||
_rootPage = rootContent;
|
||||
var subPages = ContentService.GetPagedChildren(rootContent.Id, 0, 3, out _).ToList();
|
||||
_testPage = subPages[0];
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Can_Create_Redirects()
|
||||
{
|
||||
IDictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> dict =
|
||||
new Dictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)>
|
||||
{
|
||||
[(_testPage.Id, "en")] = (_testPage.Key, "/old-route"),
|
||||
};
|
||||
var redirectTracker = CreateRedirectTracker();
|
||||
|
||||
redirectTracker.CreateRedirects(dict);
|
||||
|
||||
var redirects = RedirectUrlService.GetContentRedirectUrls(_testPage.Key);
|
||||
Assert.AreEqual(1, redirects.Count());
|
||||
var redirect = redirects.First();
|
||||
Assert.AreEqual("/old-route", redirect.Url);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Will_Remove_Self_Referencing_Redirects()
|
||||
{
|
||||
CreateExistingRedirect();
|
||||
|
||||
var redirects = RedirectUrlService.GetContentRedirectUrls(_testPage.Key);
|
||||
Assert.IsTrue(redirects.Any(x => x.Url == "/new-route")); // Ensure self referencing redirect exists.
|
||||
|
||||
IDictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)> dict =
|
||||
new Dictionary<(int ContentId, string Culture), (Guid ContentKey, string OldRoute)>
|
||||
{
|
||||
[(_testPage.Id, "en")] = (_testPage.Key, "/old-route"),
|
||||
};
|
||||
|
||||
var redirectTracker = CreateRedirectTracker();
|
||||
redirectTracker.CreateRedirects(dict);
|
||||
|
||||
redirects = RedirectUrlService.GetContentRedirectUrls(_testPage.Key);
|
||||
Assert.AreEqual(1, redirects.Count());
|
||||
var redirect = redirects.First();
|
||||
Assert.AreEqual("/old-route", redirect.Url);
|
||||
}
|
||||
|
||||
private RedirectUrlRepository CreateRedirectUrlRepository() =>
|
||||
new(
|
||||
(IScopeAccessor)ScopeProvider,
|
||||
AppCaches.Disabled,
|
||||
new NullLogger<RedirectUrlRepository>());
|
||||
|
||||
private IRedirectTracker CreateRedirectTracker()
|
||||
{
|
||||
var contentType = new Mock<IPublishedContentType>();
|
||||
contentType.SetupGet(c => c.Variations).Returns(ContentVariation.Nothing);
|
||||
|
||||
var cultures = new Dictionary<string, PublishedCultureInfo>
|
||||
{
|
||||
{ "en", new PublishedCultureInfo("en", "english", "/en/", DateTime.UtcNow) },
|
||||
};
|
||||
|
||||
var rootContent = new Mock<IPublishedContent>();
|
||||
rootContent.SetupGet(c => c.Id).Returns(_rootPage.Id);
|
||||
rootContent.SetupGet(c => c.Key).Returns(_rootPage.Key);
|
||||
rootContent.SetupGet(c => c.Name).Returns(_rootPage.Name);
|
||||
rootContent.SetupGet(c => c.Path).Returns(_rootPage.Path);
|
||||
|
||||
var content = new Mock<IPublishedContent>();
|
||||
content.SetupGet(c => c.Id).Returns(_testPage.Id);
|
||||
content.SetupGet(c => c.Key).Returns(_testPage.Key);
|
||||
content.SetupGet(c => c.Name).Returns(_testPage.Name);
|
||||
content.SetupGet(c => c.Path).Returns(_testPage.Path);
|
||||
content.SetupGet(c => c.ContentType).Returns(contentType.Object);
|
||||
content.SetupGet(c => c.Cultures).Returns(cultures);
|
||||
|
||||
IPublishedContentCache contentCache = Mock.Of<IPublishedContentCache>();
|
||||
Mock.Get(contentCache)
|
||||
.Setup(x => x.GetRouteById(_testPage.Id, "en"))
|
||||
.Returns("/new-route");
|
||||
|
||||
UmbracoContextReference contextReference = new UmbracoContextReference(Mock.Of<IUmbracoContext>(), false, Mock.Of<IUmbracoContextAccessor>());
|
||||
Mock.Get(contextReference.UmbracoContext)
|
||||
.Setup(x => x.Content)
|
||||
.Returns(contentCache);
|
||||
|
||||
IUmbracoContextFactory contextFactory = Mock.Of<IUmbracoContextFactory>();
|
||||
Mock.Get(contextFactory)
|
||||
.Setup(x => x.EnsureUmbracoContext())
|
||||
.Returns(contextReference);
|
||||
|
||||
return new RedirectTracker(
|
||||
contextFactory,
|
||||
GetRequiredService<IVariationContextAccessor>(),
|
||||
GetRequiredService<ILocalizationService>(),
|
||||
RedirectUrlService,
|
||||
GetRequiredService<ILogger<RedirectTracker>>());
|
||||
}
|
||||
|
||||
private void CreateExistingRedirect()
|
||||
{
|
||||
using var scope = ScopeProvider.CreateScope();
|
||||
var repository = CreateRedirectUrlRepository();
|
||||
repository.Save(new RedirectUrl { ContentKey = _testPage.Key, Url = "/new-route", Culture = "en" });
|
||||
scope.Complete();
|
||||
}
|
||||
}
|
||||
+358
@@ -5,12 +5,16 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Exceptions;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.ContentEditing;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.Changes;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
using Umbraco.Cms.Tests.Common.Attributes;
|
||||
using Umbraco.Cms.Tests.Common.Builders;
|
||||
using Umbraco.Cms.Tests.Common.Testing;
|
||||
@@ -35,6 +39,10 @@ public class ContentTypeServiceTests : UmbracoIntegrationTest
|
||||
{
|
||||
builder.AddNotificationHandler<ContentMovedToRecycleBinNotification, ContentNotificationHandler>();
|
||||
builder.AddNotificationHandler<ContentTypeDeletedNotification, ContentTypeNotificationHandler>();
|
||||
|
||||
builder.AddNotificationHandler<ContentTypeChangedNotification, ContentTypeChangedDistributedCacheNotificationHandler>();
|
||||
builder.AddNotificationHandler<ContentTypeCacheRefresherNotification, ContentTypeCacheRefreshedNotificationHandler>();
|
||||
builder.Services.AddUnique<IServerMessenger, ContentEventsTests.LocalServerMessenger>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -2026,6 +2034,328 @@ public class ContentTypeServiceTests : UmbracoIntegrationTest
|
||||
Assert.That(ctBase.PropertyTypes.First().PropertyEditorAlias, Is.EqualTo(dtdYesNo.EditorAlias));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Adding_ContentType_Composition_Yields_RefreshOther()
|
||||
{
|
||||
var cts = ContentTypeService;
|
||||
|
||||
// Arrange
|
||||
IContentType component = CreateComponent();
|
||||
cts.Save(component);
|
||||
IContentType site = CreateSite();
|
||||
cts.Save(site);
|
||||
|
||||
// re-fetch before acting
|
||||
site = cts.Get(site.Id)!;
|
||||
|
||||
// Act
|
||||
ContentTypeCacheRefresher.JsonPayload[] refreshedPayloads = null;
|
||||
ContentTypeCacheRefreshedNotificationHandler.ContentTypeCacheRefreshed = payloads
|
||||
=> refreshedPayloads = payloads;
|
||||
|
||||
Assert.IsTrue(site.AddContentType(component));
|
||||
Assert.IsTrue(site.ContentTypeCompositionExists(component.Alias));
|
||||
cts.Save(site);
|
||||
|
||||
// Assert; expect RefreshOther when adding a compostion
|
||||
AssertContentTypeRefreshPayload(refreshedPayloads, site.Id, ContentTypeChangeTypes.RefreshOther);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Adding_PropertyType_Yields_RefreshOther()
|
||||
{
|
||||
var cts = ContentTypeService;
|
||||
|
||||
// Arrange
|
||||
IContentType site = CreateSite();
|
||||
cts.Save(site);
|
||||
|
||||
// re-fetch before acting
|
||||
site = cts.Get(site.Id)!;
|
||||
|
||||
// Act
|
||||
ContentTypeCacheRefresher.JsonPayload[] refreshedPayloads = null;
|
||||
ContentTypeCacheRefreshedNotificationHandler.ContentTypeCacheRefreshed = payloads
|
||||
=> refreshedPayloads = payloads;
|
||||
|
||||
var propertyType =
|
||||
new PropertyType(ShortStringHelper, Constants.PropertyEditors.Aliases.TextBox, ValueStorageType.Ntext,
|
||||
"title")
|
||||
{
|
||||
Name = "Title",
|
||||
Description = string.Empty,
|
||||
Mandatory = false,
|
||||
SortOrder = 1,
|
||||
DataTypeId = -88
|
||||
};
|
||||
Assert.IsTrue(site.AddPropertyType(propertyType));
|
||||
cts.Save(site);
|
||||
|
||||
// Assert; expect RefreshOther when adding a property
|
||||
AssertContentTypeRefreshPayload(refreshedPayloads, site.Id, ContentTypeChangeTypes.RefreshOther);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Removing_ContentType_Composition_Yields_RefreshMain()
|
||||
{
|
||||
var cts = ContentTypeService;
|
||||
|
||||
// Arrange
|
||||
IContentType component = CreateComponent();
|
||||
cts.Save(component);
|
||||
IContentType site = CreateSite();
|
||||
Assert.IsTrue(site.AddContentType(component));
|
||||
cts.Save(site);
|
||||
|
||||
// re-fetch before acting
|
||||
site = cts.Get(site.Id)!;
|
||||
Assert.IsTrue(site.ContentTypeCompositionExists(component.Alias));
|
||||
|
||||
// Act
|
||||
ContentTypeCacheRefresher.JsonPayload[] refreshedPayloads = null;
|
||||
ContentTypeCacheRefreshedNotificationHandler.ContentTypeCacheRefreshed = payloads
|
||||
=> refreshedPayloads = payloads;
|
||||
|
||||
Assert.IsTrue(site.RemoveContentType(component.Alias));
|
||||
cts.Save(site);
|
||||
|
||||
// Assert; expect RefreshMain when removing a composition
|
||||
AssertContentTypeRefreshPayload(refreshedPayloads, site.Id, ContentTypeChangeTypes.RefreshMain);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Removing_PropertyType_Yields_RefreshMain()
|
||||
{
|
||||
var cts = ContentTypeService;
|
||||
|
||||
// Arrange
|
||||
IContentType site = CreateSite();
|
||||
cts.Save(site);
|
||||
|
||||
// re-fetch before acting
|
||||
site = cts.Get(site.Id)!;
|
||||
|
||||
// Act
|
||||
ContentTypeCacheRefresher.JsonPayload[] refreshedPayloads = null;
|
||||
ContentTypeCacheRefreshedNotificationHandler.ContentTypeCacheRefreshed = payloads
|
||||
=> refreshedPayloads = payloads;
|
||||
|
||||
site.RemovePropertyType(site.PropertyTypes.First().Alias);
|
||||
cts.Save(site);
|
||||
|
||||
// Assert; expect RefreshMain when removing a property
|
||||
AssertContentTypeRefreshPayload(refreshedPayloads, site.Id, ContentTypeChangeTypes.RefreshMain);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Removing_PropertyType_By_Replacing_Collection_Yields_RefreshMain()
|
||||
{
|
||||
// This test simulates how the backoffice mapper removes a property: rather than calling
|
||||
// RemovePropertyType(), it rebuilds the property collection without the removed property
|
||||
// and assigns it to the group. This is the code path in ContentTypeMapDefinition.MapSaveToTypeBase().
|
||||
var cts = ContentTypeService;
|
||||
|
||||
// Arrange
|
||||
IContentType site = CreateSite();
|
||||
cts.Save(site);
|
||||
|
||||
// re-fetch before acting
|
||||
site = cts.Get(site.Id)!;
|
||||
|
||||
// Act
|
||||
ContentTypeCacheRefresher.JsonPayload[] refreshedPayloads = null;
|
||||
ContentTypeCacheRefreshedNotificationHandler.ContentTypeCacheRefreshed = payloads
|
||||
=> refreshedPayloads = payloads;
|
||||
|
||||
// Simulate the mapper path: replace the group's PropertyTypes with an empty collection
|
||||
// (as if the property was removed via the backoffice UI)
|
||||
PropertyGroup group = site.PropertyGroups.First();
|
||||
group.PropertyTypes = new PropertyTypeCollection(true);
|
||||
cts.Save(site);
|
||||
|
||||
// Assert; expect RefreshMain when removing a property, regardless of how it was removed
|
||||
AssertContentTypeRefreshPayload(refreshedPayloads, site.Id, ContentTypeChangeTypes.RefreshMain);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Removing_PropertyTypeGroup_Yields_RefreshOther()
|
||||
{
|
||||
var cts = ContentTypeService;
|
||||
|
||||
// Arrange
|
||||
IContentType site = CreateSite();
|
||||
cts.Save(site);
|
||||
|
||||
// re-fetch before acting
|
||||
site = cts.Get(site.Id)!;
|
||||
|
||||
// Act
|
||||
ContentTypeCacheRefresher.JsonPayload[] refreshedPayloads = null;
|
||||
ContentTypeCacheRefreshedNotificationHandler.ContentTypeCacheRefreshed = payloads
|
||||
=> refreshedPayloads = payloads;
|
||||
|
||||
site.RemovePropertyGroup(site.PropertyTypes.First().Alias);
|
||||
cts.Save(site);
|
||||
|
||||
// Assert; removing a group does not cause the contained properties to be removed, so expect RefreshOther
|
||||
AssertContentTypeRefreshPayload(refreshedPayloads, site.Id, ContentTypeChangeTypes.RefreshOther);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Changing_PropertyType_Alias_Yields_RefreshMain()
|
||||
{
|
||||
var cts = ContentTypeService;
|
||||
|
||||
// Arrange
|
||||
IContentType site = CreateSite();
|
||||
cts.Save(site);
|
||||
|
||||
// re-fetch before acting
|
||||
site = cts.Get(site.Id)!;
|
||||
|
||||
// Act
|
||||
ContentTypeCacheRefresher.JsonPayload[] refreshedPayloads = null;
|
||||
ContentTypeCacheRefreshedNotificationHandler.ContentTypeCacheRefreshed = payloads
|
||||
=> refreshedPayloads = payloads;
|
||||
|
||||
site.PropertyTypes.First().Alias += "_updated";
|
||||
cts.Save(site);
|
||||
|
||||
// Assert; expect RefreshMain when changing the alias of a property (it corresponds to removing the property)
|
||||
AssertContentTypeRefreshPayload(refreshedPayloads, site.Id, ContentTypeChangeTypes.RefreshMain);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Changing_ContentType_Alias_Yields_RefreshMain()
|
||||
{
|
||||
var cts = ContentTypeService;
|
||||
|
||||
// Arrange
|
||||
IContentType site = CreateSite();
|
||||
cts.Save(site);
|
||||
|
||||
// re-fetch before acting
|
||||
site = cts.Get(site.Id)!;
|
||||
|
||||
// Act
|
||||
ContentTypeCacheRefresher.JsonPayload[] refreshedPayloads = null;
|
||||
ContentTypeCacheRefreshedNotificationHandler.ContentTypeCacheRefreshed = payloads
|
||||
=> refreshedPayloads = payloads;
|
||||
|
||||
site.Alias += "_updated";
|
||||
cts.Save(site);
|
||||
|
||||
// Assert; expect RefreshMain when changing the alias of a content type
|
||||
AssertContentTypeRefreshPayload(refreshedPayloads, site.Id, ContentTypeChangeTypes.RefreshMain);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Changing_PropertyType_Variance_Yields_RefreshMain()
|
||||
{
|
||||
var cts = ContentTypeService;
|
||||
|
||||
// Arrange
|
||||
IContentType site = CreateSite();
|
||||
site.Variations = ContentVariation.Culture;
|
||||
cts.Save(site);
|
||||
|
||||
// re-fetch before acting
|
||||
site = cts.Get(site.Id)!;
|
||||
|
||||
// Act
|
||||
ContentTypeCacheRefresher.JsonPayload[] refreshedPayloads = null;
|
||||
ContentTypeCacheRefreshedNotificationHandler.ContentTypeCacheRefreshed = payloads
|
||||
=> refreshedPayloads = payloads;
|
||||
|
||||
site.PropertyTypes.First().Variations = ContentVariation.Culture;
|
||||
cts.Save(site);
|
||||
|
||||
// Assert; expect RefreshMain when changing the variance of a property
|
||||
AssertContentTypeRefreshPayload(refreshedPayloads, site.Id, ContentTypeChangeTypes.RefreshMain);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Changing_ContentType_Variance_Yields_RefreshMain()
|
||||
{
|
||||
var cts = ContentTypeService;
|
||||
|
||||
// Arrange
|
||||
IContentType site = CreateSite();
|
||||
cts.Save(site);
|
||||
|
||||
// re-fetch before acting
|
||||
site = cts.Get(site.Id)!;
|
||||
|
||||
// Act
|
||||
ContentTypeCacheRefresher.JsonPayload[] refreshedPayloads = null;
|
||||
ContentTypeCacheRefreshedNotificationHandler.ContentTypeCacheRefreshed = payloads
|
||||
=> refreshedPayloads = payloads;
|
||||
|
||||
site.Variations = ContentVariation.Culture;
|
||||
cts.Save(site);
|
||||
|
||||
// Assert; expect RefreshMain when changing the variance of a content type
|
||||
AssertContentTypeRefreshPayload(refreshedPayloads, site.Id, ContentTypeChangeTypes.RefreshMain);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Changing_User_Interface_Settings_Yields_RefreshMain()
|
||||
{
|
||||
var cts = ContentTypeService;
|
||||
|
||||
// Arrange
|
||||
IContentType site = CreateSite();
|
||||
cts.Save(site);
|
||||
|
||||
// re-fetch before acting
|
||||
site = cts.Get(site.Id)!;
|
||||
|
||||
// Act
|
||||
ContentTypeCacheRefresher.JsonPayload[] refreshedPayloads = null;
|
||||
ContentTypeCacheRefreshedNotificationHandler.ContentTypeCacheRefreshed = payloads
|
||||
=> refreshedPayloads = payloads;
|
||||
|
||||
site.Name += "_updated";
|
||||
site.Description += "_updated";
|
||||
site.Icon += "_updated";
|
||||
site.PropertyTypes.First().Name += "_updated";
|
||||
cts.Save(site);
|
||||
|
||||
// Assert; expect RefreshOther when making UI changes only (names, icon, description etc.)
|
||||
AssertContentTypeRefreshPayload(refreshedPayloads, site.Id, ContentTypeChangeTypes.RefreshOther);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Changing_History_Cleanup_And_Basic_Structure_Settings_Yields_RefreshMain()
|
||||
{
|
||||
var cts = ContentTypeService;
|
||||
|
||||
// Arrange
|
||||
IContentType site = CreateSite();
|
||||
cts.Save(site);
|
||||
|
||||
// re-fetch before acting
|
||||
site = cts.Get(site.Id)!;
|
||||
|
||||
// Act
|
||||
ContentTypeCacheRefresher.JsonPayload[] refreshedPayloads = null;
|
||||
ContentTypeCacheRefreshedNotificationHandler.ContentTypeCacheRefreshed = payloads
|
||||
=> refreshedPayloads = payloads;
|
||||
|
||||
site.HistoryCleanup = new HistoryCleanup
|
||||
{
|
||||
KeepAllVersionsNewerThanDays = 12,
|
||||
KeepLatestVersionPerDayForDays = 32,
|
||||
PreventCleanup = false,
|
||||
};
|
||||
site.AllowedAsRoot = !site.AllowedAsRoot;
|
||||
site.AllowedContentTypes = [new ContentTypeSort(site.Id, 1)];
|
||||
cts.Save(site);
|
||||
|
||||
// Assert; expect RefreshOther when making UI changes only (names, icon, description etc.)
|
||||
AssertContentTypeRefreshPayload(refreshedPayloads, site.Id, ContentTypeChangeTypes.RefreshOther);
|
||||
}
|
||||
|
||||
private ContentType CreateComponent()
|
||||
{
|
||||
var component = new ContentType(ShortStringHelper, -1)
|
||||
@@ -2169,4 +2499,32 @@ public class ContentTypeServiceTests : UmbracoIntegrationTest
|
||||
public static Action<ContentTypeDeletedNotification> Deleted { get; set; }
|
||||
public void Handle(ContentTypeDeletedNotification notification) => Deleted?.Invoke(notification);
|
||||
}
|
||||
|
||||
public class ContentTypeCacheRefreshedNotificationHandler : INotificationHandler<ContentTypeCacheRefresherNotification>
|
||||
{
|
||||
public static Action<ContentTypeCacheRefresher.JsonPayload[]>? ContentTypeCacheRefreshed { get; set; }
|
||||
|
||||
public void Handle(ContentTypeCacheRefresherNotification notification)
|
||||
{
|
||||
if (notification.MessageType != MessageType.RefreshByPayload || notification.MessageObject is not ContentTypeCacheRefresher.JsonPayload[] payloads)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
ContentTypeCacheRefreshed?.Invoke(payloads);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AssertContentTypeRefreshPayload(ContentTypeCacheRefresher.JsonPayload[]? refreshedPayloads, int expectedContentTypeId, ContentTypeChangeTypes expectedChangeTypes)
|
||||
{
|
||||
Assert.IsNotNull(refreshedPayloads);
|
||||
Assert.AreEqual(1, refreshedPayloads.Length);
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
var payload = refreshedPayloads.First();
|
||||
Assert.AreEqual(expectedContentTypeId, payload.Id);
|
||||
Assert.AreEqual(expectedChangeTypes, payload.ChangeTypes);
|
||||
Assert.AreEqual(nameof(IContentType), payload.ItemType);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Core.Services.Changes;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Extensions;
|
||||
|
||||
[TestFixture]
|
||||
public class ContentTypeChangeExtensionsTests
|
||||
{
|
||||
[TestCase(ContentTypeChangeTypes.RefreshMain, true)]
|
||||
[TestCase(ContentTypeChangeTypes.RefreshMain | ContentTypeChangeTypes.RefreshOther, true)]
|
||||
[TestCase(ContentTypeChangeTypes.RefreshMain | ContentTypeChangeTypes.Create, true)]
|
||||
[TestCase(ContentTypeChangeTypes.RefreshOther, false)]
|
||||
[TestCase(ContentTypeChangeTypes.None, false)]
|
||||
[TestCase(ContentTypeChangeTypes.Create, false)]
|
||||
[TestCase(ContentTypeChangeTypes.Remove, false)]
|
||||
[TestCase(ContentTypeChangeTypes.RefreshOther | ContentTypeChangeTypes.Remove, false)]
|
||||
public void IsStructuralChange(ContentTypeChangeTypes change, bool expected) =>
|
||||
Assert.AreEqual(expected, change.IsStructuralChange());
|
||||
|
||||
[TestCase(ContentTypeChangeTypes.RefreshOther, true)]
|
||||
[TestCase(ContentTypeChangeTypes.RefreshOther | ContentTypeChangeTypes.Create, true)]
|
||||
[TestCase(ContentTypeChangeTypes.RefreshOther | ContentTypeChangeTypes.Remove, true)]
|
||||
[TestCase(ContentTypeChangeTypes.RefreshMain, false)]
|
||||
[TestCase(ContentTypeChangeTypes.RefreshMain | ContentTypeChangeTypes.RefreshOther, false)]
|
||||
[TestCase(ContentTypeChangeTypes.None, false)]
|
||||
[TestCase(ContentTypeChangeTypes.Create, false)]
|
||||
[TestCase(ContentTypeChangeTypes.Remove, false)]
|
||||
public void IsNonStructuralChange(ContentTypeChangeTypes change, bool expected) =>
|
||||
Assert.AreEqual(expected, change.IsNonStructuralChange());
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
@@ -14,11 +13,6 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.IO;
|
||||
[TestFixture]
|
||||
public class PhysicalFileSystemTests : AbstractFileSystemTests
|
||||
{
|
||||
[SetUp]
|
||||
public void Setup()
|
||||
{
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
@@ -44,22 +38,10 @@ public class PhysicalFileSystemTests : AbstractFileSystemTests
|
||||
Mock.Of<ILogger<PhysicalFileSystem>>(),
|
||||
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "FileSysTests"),
|
||||
"/Media/"))
|
||||
{
|
||||
}
|
||||
{ }
|
||||
|
||||
protected override string ConstructUrl(string path) => "/Media/" + path;
|
||||
|
||||
private string Repeat(string pattern, int count)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
text.Append(pattern);
|
||||
}
|
||||
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SaveFileTest()
|
||||
{
|
||||
@@ -70,16 +52,7 @@ public class PhysicalFileSystemTests : AbstractFileSystemTests
|
||||
_fileSystem.AddFile("sub/f3.txt", ms);
|
||||
}
|
||||
|
||||
Assert.IsTrue(File.Exists(Path.Combine(basePath, "sub/f3.txt")));
|
||||
|
||||
var path = Repeat("bah/bah/", 50);
|
||||
Assert.Less(260, path.Length);
|
||||
|
||||
Assert.Throws<PathTooLongException>(() =>
|
||||
{
|
||||
using var ms = new MemoryStream(Encoding.UTF8.GetBytes("foo"));
|
||||
_fileSystem.AddFile(path + "f3.txt", ms);
|
||||
});
|
||||
Assert.IsTrue(File.Exists(Path.Combine(basePath, "sub", "f3.txt")));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -98,28 +71,18 @@ public class PhysicalFileSystemTests : AbstractFileSystemTests
|
||||
// - does throw on invalid paths
|
||||
// works
|
||||
var path = _fileSystem.GetFullPath("foo.tmp");
|
||||
Assert.AreEqual(Path.Combine(basePath, @"foo.tmp"), path);
|
||||
Assert.AreEqual(Path.Combine(basePath, "foo.tmp"), path);
|
||||
|
||||
// a very long relative path, which ends up being a short path, works
|
||||
path = Repeat("bah/../", 50);
|
||||
Assert.Less(260, path.Length);
|
||||
// normalize path with parent directory references
|
||||
path = "foo/../bar/../bah/../";
|
||||
path = _fileSystem.GetFullPath(path + "foo.tmp");
|
||||
Assert.AreEqual(Path.Combine(basePath, @"foo.tmp"), path);
|
||||
Assert.AreEqual(Path.Combine(basePath, "foo.tmp"), path);
|
||||
|
||||
// works too
|
||||
path = _fileSystem.GetFullPath("foo/bar.tmp");
|
||||
Assert.AreEqual(Path.Combine(basePath, @$"foo{Path.DirectorySeparatorChar}bar.tmp"), path);
|
||||
Assert.AreEqual(Path.Combine(basePath, "foo", "bar.tmp"), path);
|
||||
|
||||
// that path is invalid as it would be outside the root directory
|
||||
Assert.Throws<UnauthorizedAccessException>(() => _fileSystem.GetFullPath("../../foo.tmp"));
|
||||
|
||||
// a very long path, which ends up being very long, works
|
||||
path = Repeat("bah/bah/", 50);
|
||||
Assert.Less(260, path.Length);
|
||||
Assert.Throws<PathTooLongException>(() =>
|
||||
{
|
||||
path = _fileSystem.GetFullPath(path + "foo.tmp");
|
||||
Assert.Less(260, path.Length); // gets a >260 path and it's fine (but Windows will not like it)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,4 +133,197 @@ public class HtmlLocalLinkParserTests
|
||||
Assert.AreEqual(result, output);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ParseLocalLinks_WithUrlMode_RespectsUrlMode()
|
||||
{
|
||||
// Arrange
|
||||
var input = "hello href=\"{localLink:umb://document/9931BDE0AAC34BABB838909A7B47570E}\" world";
|
||||
|
||||
// Setup content URL provider that returns different URLs based on UrlMode
|
||||
var contentUrlProvider = new Mock<IUrlProvider>();
|
||||
contentUrlProvider
|
||||
.Setup(x => x.GetUrl(
|
||||
It.IsAny<IPublishedContent>(),
|
||||
UrlMode.Relative,
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Uri>()))
|
||||
.Returns(UrlInfo.Url("/relative-url"));
|
||||
contentUrlProvider
|
||||
.Setup(x => x.GetUrl(
|
||||
It.IsAny<IPublishedContent>(),
|
||||
UrlMode.Absolute,
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Uri>()))
|
||||
.Returns(UrlInfo.Url("http://example.com/absolute-url"));
|
||||
|
||||
var contentType = new PublishedContentType(
|
||||
Guid.NewGuid(),
|
||||
666,
|
||||
"alias",
|
||||
PublishedItemType.Content,
|
||||
Enumerable.Empty<string>(),
|
||||
Enumerable.Empty<PublishedPropertyType>(),
|
||||
ContentVariation.Nothing);
|
||||
var publishedContent = new Mock<IPublishedContent>();
|
||||
publishedContent.Setup(x => x.Id).Returns(1234);
|
||||
publishedContent.Setup(x => x.ContentType).Returns(contentType);
|
||||
|
||||
var umbracoContextAccessor = new TestUmbracoContextAccessor();
|
||||
var umbracoContextFactory = TestUmbracoContextFactory.Create(
|
||||
umbracoContextAccessor: umbracoContextAccessor);
|
||||
|
||||
var webRoutingSettings = new WebRoutingSettings();
|
||||
var publishedUrlProvider = new UrlProvider(
|
||||
umbracoContextAccessor,
|
||||
Options.Create(webRoutingSettings),
|
||||
new UrlProviderCollection(() => new[] { contentUrlProvider.Object }),
|
||||
new MediaUrlProviderCollection(() => Array.Empty<IMediaUrlProvider>()),
|
||||
Mock.Of<IVariationContextAccessor>());
|
||||
|
||||
using (var reference = umbracoContextFactory.EnsureUmbracoContext())
|
||||
{
|
||||
var contentCache = Mock.Get(reference.UmbracoContext.Content);
|
||||
contentCache.Setup(x => x.GetById(It.IsAny<Guid>())).Returns(publishedContent.Object);
|
||||
|
||||
var linkParser = new HtmlLocalLinkParser(umbracoContextAccessor, publishedUrlProvider);
|
||||
|
||||
// Act
|
||||
var relativeOutput = linkParser.EnsureInternalLinks(input, UrlMode.Relative);
|
||||
var absoluteOutput = linkParser.EnsureInternalLinks(input, UrlMode.Absolute);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("hello href=\"/relative-url\" world", relativeOutput);
|
||||
Assert.AreEqual("hello href=\"http://example.com/absolute-url\" world", absoluteOutput);
|
||||
}
|
||||
}
|
||||
|
||||
[TestCase(UrlMode.Default, "hello href=\"{localLink:1234}\" world ", "hello href=\"/relative-url\" world ")]
|
||||
[TestCase(UrlMode.Relative, "hello href=\"{localLink:1234}\" world ", "hello href=\"/relative-url\" world ")]
|
||||
[TestCase(UrlMode.Absolute, "hello href=\"{localLink:1234}\" world ", "hello href=\"https://example.com/absolute-url\" world ")]
|
||||
[TestCase(UrlMode.Auto, "hello href=\"{localLink:1234}\" world ", "hello href=\"/relative-url\" world ")]
|
||||
[TestCase(UrlMode.Default, "hello href=\"{localLink:umb://document/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"/relative-url\" world ")]
|
||||
[TestCase(UrlMode.Relative, "hello href=\"{localLink:umb://document/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"/relative-url\" world ")]
|
||||
[TestCase(UrlMode.Absolute, "hello href=\"{localLink:umb://document/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"https://example.com/absolute-url\" world ")]
|
||||
[TestCase(UrlMode.Auto, "hello href=\"{localLink:umb://document/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"/relative-url\" world ")]
|
||||
[TestCase(UrlMode.Default, "hello href=\"{localLink:umb://media/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"/media/relative/image.jpg\" world ")]
|
||||
[TestCase(UrlMode.Relative, "hello href=\"{localLink:umb://media/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"/media/relative/image.jpg\" world ")]
|
||||
[TestCase(UrlMode.Absolute, "hello href=\"{localLink:umb://media/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"https://example.com/media/absolute/image.jpg\" world ")]
|
||||
[TestCase(UrlMode.Auto, "hello href=\"{localLink:umb://media/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"/media/relative/image.jpg\" world ")]
|
||||
public void ParseLocalLinks_WithVariousUrlModes_ReturnsCorrectUrls(UrlMode urlMode, string input, string expectedResult)
|
||||
{
|
||||
// setup content URL provider that returns different URLs based on UrlMode
|
||||
var contentUrlProvider = new Mock<IUrlProvider>();
|
||||
contentUrlProvider
|
||||
.Setup(x => x.GetUrl(
|
||||
It.IsAny<IPublishedContent>(),
|
||||
UrlMode.Default,
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Uri>()))
|
||||
.Returns(UrlInfo.Url("/relative-url"));
|
||||
contentUrlProvider
|
||||
.Setup(x => x.GetUrl(
|
||||
It.IsAny<IPublishedContent>(),
|
||||
UrlMode.Relative,
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Uri>()))
|
||||
.Returns(UrlInfo.Url("/relative-url"));
|
||||
contentUrlProvider
|
||||
.Setup(x => x.GetUrl(
|
||||
It.IsAny<IPublishedContent>(),
|
||||
UrlMode.Absolute,
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Uri>()))
|
||||
.Returns(UrlInfo.Url("https://example.com/absolute-url"));
|
||||
contentUrlProvider
|
||||
.Setup(x => x.GetUrl(
|
||||
It.IsAny<IPublishedContent>(),
|
||||
UrlMode.Auto,
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Uri>()))
|
||||
.Returns(UrlInfo.Url("/relative-url"));
|
||||
|
||||
var contentType = new PublishedContentType(
|
||||
Guid.NewGuid(),
|
||||
666,
|
||||
"alias",
|
||||
PublishedItemType.Content,
|
||||
Enumerable.Empty<string>(),
|
||||
Enumerable.Empty<PublishedPropertyType>(),
|
||||
ContentVariation.Nothing);
|
||||
var publishedContent = new Mock<IPublishedContent>();
|
||||
publishedContent.Setup(x => x.Id).Returns(1234);
|
||||
publishedContent.Setup(x => x.ContentType).Returns(contentType);
|
||||
|
||||
// setup media URL provider that returns different URLs based on UrlMode
|
||||
var mediaUrlProvider = new Mock<IMediaUrlProvider>();
|
||||
mediaUrlProvider.Setup(x => x.GetMediaUrl(
|
||||
It.IsAny<IPublishedContent>(),
|
||||
It.IsAny<string>(),
|
||||
UrlMode.Default,
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Uri>()))
|
||||
.Returns(UrlInfo.Url("/media/relative/image.jpg"));
|
||||
mediaUrlProvider.Setup(x => x.GetMediaUrl(
|
||||
It.IsAny<IPublishedContent>(),
|
||||
It.IsAny<string>(),
|
||||
UrlMode.Relative,
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Uri>()))
|
||||
.Returns(UrlInfo.Url("/media/relative/image.jpg"));
|
||||
mediaUrlProvider.Setup(x => x.GetMediaUrl(
|
||||
It.IsAny<IPublishedContent>(),
|
||||
It.IsAny<string>(),
|
||||
UrlMode.Absolute,
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Uri>()))
|
||||
.Returns(UrlInfo.Url("https://example.com/media/absolute/image.jpg"));
|
||||
mediaUrlProvider.Setup(x => x.GetMediaUrl(
|
||||
It.IsAny<IPublishedContent>(),
|
||||
It.IsAny<string>(),
|
||||
UrlMode.Auto,
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<Uri>()))
|
||||
.Returns(UrlInfo.Url("/media/relative/image.jpg"));
|
||||
|
||||
var mediaType = new PublishedContentType(
|
||||
Guid.NewGuid(),
|
||||
777,
|
||||
"image",
|
||||
PublishedItemType.Media,
|
||||
Enumerable.Empty<string>(),
|
||||
Enumerable.Empty<PublishedPropertyType>(),
|
||||
ContentVariation.Nothing);
|
||||
var media = new Mock<IPublishedContent>();
|
||||
media.Setup(x => x.ContentType).Returns(mediaType);
|
||||
|
||||
var umbracoContextAccessor = new TestUmbracoContextAccessor();
|
||||
var umbracoContextFactory = TestUmbracoContextFactory.Create(
|
||||
umbracoContextAccessor: umbracoContextAccessor);
|
||||
|
||||
var webRoutingSettings = new WebRoutingSettings();
|
||||
var publishedUrlProvider = new UrlProvider(
|
||||
umbracoContextAccessor,
|
||||
Options.Create(webRoutingSettings),
|
||||
new UrlProviderCollection(() => new[] { contentUrlProvider.Object }),
|
||||
new MediaUrlProviderCollection(() => new[] { mediaUrlProvider.Object }),
|
||||
Mock.Of<IVariationContextAccessor>());
|
||||
|
||||
using (var reference = umbracoContextFactory.EnsureUmbracoContext())
|
||||
{
|
||||
var contentCache = Mock.Get(reference.UmbracoContext.Content);
|
||||
contentCache.Setup(x => x.GetById(It.IsAny<int>())).Returns(publishedContent.Object);
|
||||
contentCache.Setup(x => x.GetById(It.IsAny<Guid>())).Returns(publishedContent.Object);
|
||||
|
||||
var mediaCache = Mock.Get(reference.UmbracoContext.Media);
|
||||
mediaCache.Setup(x => x.GetById(It.IsAny<int>())).Returns(media.Object);
|
||||
mediaCache.Setup(x => x.GetById(It.IsAny<Guid>())).Returns(media.Object);
|
||||
|
||||
var linkParser = new HtmlLocalLinkParser(umbracoContextAccessor, publishedUrlProvider);
|
||||
|
||||
var output = linkParser.EnsureInternalLinks(input, urlMode);
|
||||
|
||||
Assert.AreEqual(expectedResult, output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json",
|
||||
"version": "13.11.0-rc",
|
||||
"version": "13.15.0-rc",
|
||||
"assemblyVersion": {
|
||||
"precision": "build"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user