Compare commits
191
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9505011d71 | ||
|
|
2579aaf2db | ||
|
|
2b7784a226 | ||
|
|
8555a97b39 | ||
|
|
c2dd685a4b | ||
|
|
66fc819379 | ||
|
|
4f1f7e15c4 | ||
|
|
a826c52e2e | ||
|
|
8b2c22aaf1 | ||
|
|
aecfee4469 | ||
|
|
9c785a9c5b | ||
|
|
2fe10387ee | ||
|
|
8642b9e615 | ||
|
|
2a604c8719 | ||
|
|
9c0a0a1086 | ||
|
|
1a4256f997 | ||
|
|
80ae0380a2 | ||
|
|
fd01282798 | ||
|
|
f7ba2eaa62 | ||
|
|
9485a95c0e | ||
|
|
f1ab605bb9 | ||
|
|
3472ff9ba3 | ||
|
|
577dc06d55 | ||
|
|
f4771d1495 | ||
|
|
4b3ce53acf | ||
|
|
ca267047d3 | ||
|
|
0543163817 | ||
|
|
72f43a5821 | ||
|
|
aea9034adf | ||
|
|
6e6f822761 | ||
|
|
137aa20a10 | ||
|
|
d7231c5435 | ||
|
|
be116436d9 | ||
|
|
aed7505e4b | ||
|
|
590a020303 | ||
|
|
15c6ca7628 | ||
|
|
49ba89c22a | ||
|
|
c295271757 | ||
|
|
12b483ff05 | ||
|
|
7502a38033 | ||
|
|
4e74dbf218 | ||
|
|
fa5c53b571 | ||
|
|
76fed82e91 | ||
|
|
43ac32282c | ||
|
|
96ecef0a92 | ||
|
|
5e87dead44 | ||
|
|
7af67d2944 | ||
|
|
ce59537006 | ||
|
|
f87e15b941 | ||
|
|
1f82bdde3d | ||
|
|
0d2393d866 | ||
|
|
bea21d7b99 | ||
|
|
3dc65c48b3 | ||
|
|
18ab333afc | ||
|
|
fd91f88a7e | ||
|
|
13c164d81f | ||
|
|
f33eb3f678 | ||
|
|
e893682723 | ||
|
|
d9c201e3d1 | ||
|
|
a5fcfc231d | ||
|
|
6ba03a48c8 | ||
|
|
8434c7d0cb | ||
|
|
3854b2bd53 | ||
|
|
08d217360e | ||
|
|
b762135554 | ||
|
|
6bc498ad41 | ||
|
|
f88e28d642 | ||
|
|
194fee7c91 | ||
|
|
4a65f56d9d | ||
|
|
62c1d44a5d | ||
|
|
c2eea5d6cc | ||
|
|
21bf23b67d | ||
|
|
48759b9852 | ||
|
|
79639c0571 | ||
|
|
0792e4358b | ||
|
|
caeb3454e1 | ||
|
|
942ccc82d9 | ||
|
|
daace4b4a0 | ||
|
|
1ceec183a3 | ||
|
|
81a8a0c191 | ||
|
|
ae41438a36 | ||
|
|
5337c38f2c | ||
|
|
7751e40ba8 | ||
|
|
d5a2f0572e | ||
|
|
5a65eb1758 | ||
|
|
105cb9da41 | ||
|
|
a3a8be4717 | ||
|
|
d17ba805b2 | ||
|
|
96f597e440 | ||
|
|
31bcbc1147 | ||
|
|
369b020d9d | ||
|
|
ec354cef92 | ||
|
|
4c05a114c5 | ||
|
|
e71f36d816 | ||
|
|
fdf759d08d | ||
|
|
1ab13a970b | ||
|
|
e22b459d9c | ||
|
|
a19b9fb5fe | ||
|
|
cdf9ee4566 | ||
|
|
e53220c8f5 | ||
|
|
12adfd52bd | ||
|
|
494674d354 | ||
|
|
4ba186633c | ||
|
|
e336f9dfb0 | ||
|
|
3ac37f3686 | ||
|
|
0a027dd80d | ||
|
|
7b4684cd70 | ||
|
|
3df8b9e41a | ||
|
|
fd34ce5bd7 | ||
|
|
99c2aaf17a | ||
|
|
a4c373d3b5 | ||
|
|
bcedc8de2a | ||
|
|
767894b723 | ||
|
|
1fe7931d07 | ||
|
|
16132b0075 | ||
|
|
9a18dd547d | ||
|
|
7ecc6ece7e | ||
|
|
a676dc4bad | ||
|
|
9fe8b2162b | ||
|
|
429f8b0bed | ||
|
|
1a462bcc7d | ||
|
|
eaac515b30 | ||
|
|
c6e5df40c9 | ||
|
|
193d8af9af | ||
|
|
dc92cf4b19 | ||
|
|
edf95e6fab | ||
|
|
adf8708987 | ||
|
|
de2b15d89e | ||
|
|
913b79d682 | ||
|
|
4197ca637b | ||
|
|
df81264794 | ||
|
|
e9ae3cf5db | ||
|
|
f5da0db977 | ||
|
|
cb8c331777 | ||
|
|
4af9b1e156 | ||
|
|
b4e97ea49f | ||
|
|
d400ba5899 | ||
|
|
184c17e2c8 | ||
|
|
4330a99830 | ||
|
|
fa5f6b9acc | ||
|
|
7819b6dfac | ||
|
|
11b62518ae | ||
|
|
f3d121d91f | ||
|
|
84fb1f4d6c | ||
|
|
e7fde8c01f | ||
|
|
a95a2b955f | ||
|
|
02b93e90cb | ||
|
|
16c0de803b | ||
|
|
e80cc1a9f0 | ||
|
|
963331448a | ||
|
|
e5c942b459 | ||
|
|
28fa373b0c | ||
|
|
e0449d3c00 | ||
|
|
5a565b7e8d | ||
|
|
cd1cbb3b15 | ||
|
|
36dbab2289 | ||
|
|
3f8ad2238f | ||
|
|
42c7b744c5 | ||
|
|
589285d081 | ||
|
|
61277b3812 | ||
|
|
e6d1d7a6b9 | ||
|
|
5d64f51003 | ||
|
|
1f747d3b7f | ||
|
|
b35db9cb5b | ||
|
|
436be6ec3f | ||
|
|
cf61356b80 | ||
|
|
0d98527c58 | ||
|
|
78f4caa2be | ||
|
|
3f0428c8ef | ||
|
|
b261628b4b | ||
|
|
a84d67eff8 | ||
|
|
1082bf17b2 | ||
|
|
418043bf59 | ||
|
|
65393ff3d9 | ||
|
|
82fc41a459 | ||
|
|
1ca7f3162f | ||
|
|
a30f837384 | ||
|
|
2758813547 | ||
|
|
fbbbc45c24 | ||
|
|
fc60b5b5ff | ||
|
|
84811046f1 | ||
|
|
7572ef5fff | ||
|
|
61f1c4abd0 | ||
|
|
cf10a59a4c | ||
|
|
149b191cf4 | ||
|
|
9e3b27cfdd | ||
|
|
54b49f90bd | ||
|
|
e93802769c | ||
|
|
1aa8bdf3aa | ||
|
|
a8cdba6e70 | ||
|
|
6001d94f50 |
+10
-3
@@ -37,7 +37,7 @@ In order to work with the Umbraco source code locally, first make sure you have
|
||||
|
||||
### Familiarizing yourself with the code
|
||||
|
||||
Umbraco is a .NET application using C#. The solution is broken down into multiple projects. There are several class libraries. The `Umbraco.Web.UI` project is the main project that hosts the back office and login screen. This is the project you will want to run to see your changes.
|
||||
Umbraco is a .NET application using C#. The solution is broken down into multiple projects. There are several class libraries. The `Umbraco.Web.UI` project is the main project that hosts the back office and login screen. This is the project you will want to run to see your changes.
|
||||
|
||||
There are two web projects in the solution with client-side assets based on TypeScript, `Umbraco.Web.UI.Client` and `Umbraco.Web.UI.Login`.
|
||||
|
||||
@@ -73,13 +73,20 @@ Just be careful not to include this change in your PR.
|
||||
|
||||
Conversely, if you are working on front-end only, you want to build the back-end once and then run it. Before you do so, update the configuration in `appSettings.json` to add the following under `Umbraco:Cms:Security`:
|
||||
|
||||
```
|
||||
```json
|
||||
"BackOfficeHost": "http://localhost:5173",
|
||||
"AuthorizeCallbackPathName": "/oauth_complete",
|
||||
"AuthorizeCallbackLogoutPathName": "/logout",
|
||||
"AuthorizeCallbackErrorPathName": "/error"
|
||||
"AuthorizeCallbackErrorPathName": "/error",
|
||||
"BackOfficeTokenCookie": {
|
||||
"Enabled": true,
|
||||
"SameSite": "None"
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> If you get stuck in a login loop, try clearing your browser cookies for localhost, and make sure that the `BackOfficeTokenCookie` settings are correct. Namely, that `SameSite` should be set to `None` when running the front-end server separately.
|
||||
|
||||
Then run Umbraco from the command line.
|
||||
|
||||
```
|
||||
|
||||
+10
-1
@@ -38,9 +38,17 @@ Some important documentation links to get you started:
|
||||
- [Getting to know Umbraco](https://docs.umbraco.com/umbraco-cms/fundamentals/get-to-know-umbraco)
|
||||
- [Tutorials for creating a basic website and customizing the editing experience](https://docs.umbraco.com/umbraco-cms/tutorials/overview)
|
||||
|
||||
## Backoffice Preview
|
||||
|
||||
Want to see the latest backoffice UI in action? Check out our live preview:
|
||||
|
||||
**[backofficepreview.umbraco.com](https://backofficepreview.umbraco.com/)**
|
||||
|
||||
This preview is automatically deployed from the main branch and showcases the latest backoffice features and improvements. It runs from mock data and persistent edits are not supported.
|
||||
|
||||
## Get help
|
||||
|
||||
If you need a bit of feedback while building your Umbraco projects, we are [chatty on Discord](https://discord.umbraco.com). Our Discord server serves both a social space but also has channels for questions and answers. Feel free to lurk or join in with your own questions. Or just post your daily Wordle score, up to you!
|
||||
If you need a bit of feedback while building your Umbraco projects, we are [chatty on Discord](https://discord.umbraco.com). Our Discord server serves as a social space for all Umbracians. If you have any questions or need some help with a problem, head over to our [dedicated forum](https://forum.umbraco.com/) where the Umbraco Community will be happy to help.
|
||||
|
||||
## Looking to contribute back to Umbraco?
|
||||
|
||||
@@ -52,3 +60,4 @@ You came to the right place! Our GitHub repository is available for all kinds of
|
||||
Umbraco is contribution-focused and community-driven. If you want to contribute back to the Umbraco source code, please check out our [guide to contributing](CONTRIBUTING.md).
|
||||
|
||||
### Tip: You should not run Umbraco from source code found here. Umbraco is extremely extensible and can do whatever you need. Instead, [install Umbraco as noted above](#looking-to-install-umbraco) and then [extend it any way you want to](https://docs.umbraco.com/umbraco-cms/extending/).
|
||||
|
||||
|
||||
@@ -94,19 +94,34 @@ The solution contains 30 C# projects organized as follows:
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Frontend Development
|
||||
For frontend-only changes:
|
||||
1. Configure backend for frontend development:
|
||||
```json
|
||||
<!-- Add to src/Umbraco.Web.UI/appsettings.json under Umbraco:Cms:Security: -->
|
||||
### Running Umbraco in Different Modes
|
||||
|
||||
**Production Mode (Standard Development)**
|
||||
Use this for backend development, testing full builds, or when you don't need hot reloading:
|
||||
1. Build frontend assets: `cd src/Umbraco.Web.UI.Client && npm run build:for:cms`
|
||||
2. Run backend: `cd src/Umbraco.Web.UI && dotnet run --no-build`
|
||||
3. Access backoffice: `https://localhost:44339/umbraco`
|
||||
4. Application uses compiled frontend from `wwwroot/umbraco/backoffice/`
|
||||
|
||||
**Vite Dev Server Mode (Frontend Development with Hot Reload)**
|
||||
Use this for frontend-only development with hot module reloading:
|
||||
1. Configure backend for frontend development - Add to `src/Umbraco.Web.UI/appsettings.json` under `Umbraco:CMS:Security`:
|
||||
```json
|
||||
"BackOfficeHost": "http://localhost:5173",
|
||||
"AuthorizeCallbackPathName": "/oauth_complete",
|
||||
"AuthorizeCallbackLogoutPathName": "/logout",
|
||||
"AuthorizeCallbackErrorPathName": "/error"
|
||||
"AuthorizeCallbackErrorPathName": "/error",
|
||||
"BackOfficeTokenCookie": {
|
||||
"Enabled": true,
|
||||
"SameSite": "None"
|
||||
}
|
||||
```
|
||||
2. Run backend: `cd src/Umbraco.Web.UI && dotnet run --no-build`
|
||||
3. Run frontend dev server: `cd src/Umbraco.Web.UI.Client && npm run dev:server`
|
||||
4. Access backoffice: `http://localhost:5173/` (no `/umbraco` prefix)
|
||||
5. Changes to TypeScript/Lit files hot reload automatically
|
||||
|
||||
**Important:** Remove the `BackOfficeHost` configuration before committing or switching back to production mode.
|
||||
|
||||
### Backend-Only Development
|
||||
For backend-only changes, disable frontend builds:
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
name: Create a release discussions for each new version label
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 * * * *" # every hour
|
||||
workflow_dispatch: # allow manual runs
|
||||
permissions:
|
||||
contents: read
|
||||
discussions: write
|
||||
issues: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
reconcile:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Reconcile release/* labels → discussions
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const categoryName = "Releases";
|
||||
|
||||
// 24h cutoff
|
||||
const since = new Date(Date.now() - 24*60*60*1000).toISOString();
|
||||
core.info(`Scanning issues/PRs updated since ${since}`);
|
||||
|
||||
// fetch repo + discussion categories
|
||||
const repoData = await github.graphql(`
|
||||
query($owner:String!, $repo:String!){
|
||||
repository(owner:$owner, name:$repo){
|
||||
id
|
||||
discussionCategories(first:100){ nodes { id name } }
|
||||
}
|
||||
}
|
||||
`, { owner, repo });
|
||||
const repoId = repoData.repository.id;
|
||||
const category = repoData.repository.discussionCategories.nodes.find(c => c.name === categoryName);
|
||||
if (!category) {
|
||||
core.setFailed(`Discussion category "${categoryName}" not found`);
|
||||
return;
|
||||
}
|
||||
const categoryId = category.id;
|
||||
|
||||
// paginate issues/PRs updated in last 24h
|
||||
for await (const { data: items } of github.paginate.iterator(
|
||||
github.rest.issues.listForRepo,
|
||||
{ owner, repo, state: "all", since, per_page: 100 }
|
||||
)) {
|
||||
for (const item of items) {
|
||||
const releaseLabels = (item.labels || [])
|
||||
.map(l => (typeof l === "string" ? l : l.name)) // always get the name
|
||||
.filter(n => typeof n === "string" && n.startsWith("release/") && n !== "release/no-notes");
|
||||
if (releaseLabels.length === 0) continue;
|
||||
|
||||
core.info(`#${item.number}: ${releaseLabels.join(", ")}`);
|
||||
|
||||
for (const labelName of releaseLabels) {
|
||||
const version = labelName.substring("release/".length);
|
||||
const titleTarget = `Release: ${version}`;
|
||||
|
||||
// search discussions
|
||||
let discussionId = null;
|
||||
let cursor = null;
|
||||
while (true) {
|
||||
const page = await github.graphql(`
|
||||
query($owner:String!, $repo:String!, $cursor:String){
|
||||
repository(owner:$owner, name:$repo){
|
||||
discussions(first:50, after:$cursor){
|
||||
nodes{
|
||||
id
|
||||
title
|
||||
url
|
||||
category{ name }
|
||||
labels(first:50){ nodes{ name } }
|
||||
}
|
||||
pageInfo{ hasNextPage endCursor }
|
||||
}
|
||||
}
|
||||
}
|
||||
`, { owner, repo, cursor });
|
||||
const nodes = page.repository.discussions.nodes;
|
||||
const byLabel = nodes.find(d =>
|
||||
d.category?.name === categoryName &&
|
||||
d.labels?.nodes?.some(l => l.name === labelName)
|
||||
);
|
||||
if (byLabel) { discussionId = byLabel.id; break; }
|
||||
const byTitle = nodes.find(d =>
|
||||
d.category?.name === categoryName &&
|
||||
d.title === titleTarget
|
||||
);
|
||||
if (byTitle) { discussionId = byTitle.id; break; }
|
||||
if (!page.repository.discussions.pageInfo.hasNextPage) break;
|
||||
cursor = page.repository.discussions.pageInfo.endCursor;
|
||||
}
|
||||
|
||||
if (!discussionId) {
|
||||
core.info(`→ Creating discussion for ${labelName}`);
|
||||
const body =
|
||||
`**Release date:** TODO (YYYY-MM-DD)\n\n` +
|
||||
`### Links\n` +
|
||||
`- [Issues and pull requests marked for version ${version}](https://github.com/${owner}/${repo}/issues?q=label%3A${encodeURIComponent(labelName)})\n`;
|
||||
|
||||
const created = await github.graphql(`
|
||||
mutation($repoId:ID!, $catId:ID!, $title:String!, $body:String!){
|
||||
createDiscussion(input:{
|
||||
repositoryId:$repoId,
|
||||
categoryId:$catId,
|
||||
title:$title,
|
||||
body:$body
|
||||
}){ discussion{ id url } }
|
||||
}
|
||||
`, { repoId, catId: categoryId, title: titleTarget, body });
|
||||
|
||||
discussionId = created.createDiscussion.discussion.id;
|
||||
|
||||
// lock the discussion to prevent replies
|
||||
await github.graphql(`
|
||||
mutation($id:ID!){
|
||||
lockLockable(input:{ lockableId:$id }) {
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`, { id: discussionId });
|
||||
core.info(`🔒 Locked discussion ${discussionId}`);
|
||||
} else {
|
||||
core.info(`→ Found existing discussion for ${labelName}`);
|
||||
}
|
||||
|
||||
// ensure label exists
|
||||
let labelId;
|
||||
try {
|
||||
await github.rest.issues.getLabel({ owner, repo, name: labelName });
|
||||
} catch (e) {
|
||||
if (e.status === 404) {
|
||||
await github.rest.issues.createLabel({
|
||||
owner, repo, name: labelName, color: "0E8A16"
|
||||
});
|
||||
} else { throw e; }
|
||||
}
|
||||
const labelNode = await github.graphql(`
|
||||
query($owner:String!, $repo:String!, $name:String!){
|
||||
repository(owner:$owner, name:$repo){ label(name:$name){ id } }
|
||||
}
|
||||
`, { owner, repo, name: labelName });
|
||||
labelId = labelNode.repository.label?.id;
|
||||
if (!labelId) continue;
|
||||
|
||||
// add label to discussion
|
||||
await github.graphql(`
|
||||
mutation($id:ID!, $labels:[ID!]!){
|
||||
addLabelsToLabelable(input:{ labelableId:$id, labelIds:$labels }) {
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`, { id: discussionId, labels: [labelId] });
|
||||
|
||||
core.info(`✓ ${labelName} attached to discussion`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+5
-1
@@ -101,10 +101,14 @@
|
||||
"env": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"ASPNETCORE_URLS": "https://localhost:44339",
|
||||
"UMBRACO__CMS__WEBROUTING__UMBRACOAPPLICATIONURL": "https://localhost:44339",
|
||||
"UMBRACO__CMS__SECURITY__BACKOFFICEHOST": "http://localhost:5173",
|
||||
"UMBRACO__CMS__SECURITY__AUTHORIZECALLBACKPATHNAME": "/oauth_complete",
|
||||
"UMBRACO__CMS__SECURITY__AUTHORIZECALLBACKLOGOUTPATHNAME": "/logout",
|
||||
"UMBRACO__CMS__SECURITY__AUTHORIZECALLBACKERRORPATHNAME": "/error"
|
||||
"UMBRACO__CMS__SECURITY__AUTHORIZECALLBACKERRORPATHNAME": "/error",
|
||||
"UMBRACO__CMS__SECURITY__KEEPUSERLOGGEDIN": "true",
|
||||
"UMBRACO__CMS__SECURITY__BACKOFFICETOKENCOOKIE__ENABLED": "true",
|
||||
"UMBRACO__CMS__SECURITY__BACKOFFICETOKENCOOKIE__SAMESITE": "None"
|
||||
},
|
||||
"sourceFileMap": {
|
||||
"/Views": "${workspaceFolder}/Umbraco.Web.UI/Views"
|
||||
|
||||
@@ -14,14 +14,11 @@
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="9.0.4" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.13.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.13.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.13.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Workspaces.Common" Version="4.13.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="4.13.0" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.4" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.4" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.4" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.4" />
|
||||
|
||||
@@ -34,6 +34,10 @@ parameters:
|
||||
displayName: Upload API docs
|
||||
type: boolean
|
||||
default: false
|
||||
- name: uploadDependencyTrack
|
||||
displayName: Upload BOMs to Dependency Track
|
||||
type: boolean
|
||||
default: false
|
||||
- name: forceReleaseTestFilter
|
||||
displayName: Force to use the release test filters
|
||||
type: boolean
|
||||
@@ -103,6 +107,15 @@ stages:
|
||||
command: build
|
||||
projects: $(solution)
|
||||
arguments: "--configuration $(buildConfiguration) --no-restore --property:ContinuousIntegrationBuild=true --property:GeneratePackageOnBuild=true --property:PackageOutputPath=$(Build.ArtifactStagingDirectory)/nupkg"
|
||||
- powershell: |
|
||||
dotnet tool install --global CycloneDX
|
||||
dotnet-CycloneDX $(solution) --output $(Build.ArtifactStagingDirectory)/bom --filename bom-dotnet.xml
|
||||
displayName: 'Generate Backend BOM'
|
||||
- powershell: |
|
||||
npm install --global @cyclonedx/cyclonedx-npm
|
||||
cyclonedx-npm -o $(Build.ArtifactStagingDirectory)\bom\bom-login.xml --ignore-npm-errors --verbose
|
||||
displayName: Generate Login UI BOM
|
||||
workingDirectory: src/Umbraco.Web.UI.Login
|
||||
- task: PublishPipelineArtifact@1
|
||||
displayName: Publish nupkg
|
||||
inputs:
|
||||
@@ -113,6 +126,11 @@ stages:
|
||||
inputs:
|
||||
targetPath: $(Build.SourcesDirectory)
|
||||
artifactName: build_output
|
||||
- task: PublishPipelineArtifact@1
|
||||
displayName: Publish Backend BOM
|
||||
inputs:
|
||||
targetPath: $(Build.ArtifactStagingDirectory)/bom
|
||||
artifactName: bom-backend
|
||||
|
||||
- job: B
|
||||
displayName: Build Bellissima Package
|
||||
@@ -124,6 +142,11 @@ stages:
|
||||
lfs: false,
|
||||
fetchDepth: 500
|
||||
- template: templates/backoffice-install.yml
|
||||
- powershell: |
|
||||
npm install --global @cyclonedx/cyclonedx-npm
|
||||
cyclonedx-npm -o $(Build.ArtifactStagingDirectory)/bom/bom-backoffice.xml --ignore-npm-errors --verbose
|
||||
displayName: Generate Backoffice UI BOM
|
||||
workingDirectory: src/Umbraco.Web.UI.Client
|
||||
- script: npm run build:for:npm
|
||||
displayName: Run build:for:npm
|
||||
workingDirectory: src/Umbraco.Web.UI.Client
|
||||
@@ -140,6 +163,35 @@ stages:
|
||||
inputs:
|
||||
targetPath: $(Build.ArtifactStagingDirectory)/npm
|
||||
artifactName: npm
|
||||
- publish: $(Build.ArtifactStagingDirectory)/bom
|
||||
artifact: bom-frontend
|
||||
displayName: 'Publish Frontend BOM'
|
||||
|
||||
- stage: E2E_BOM
|
||||
displayName: E2E Tests BOM Generation
|
||||
dependsOn: []
|
||||
jobs:
|
||||
- job:
|
||||
displayName: E2E Generate BOM
|
||||
pool:
|
||||
vmImage: "ubuntu-latest"
|
||||
steps:
|
||||
- checkout: self
|
||||
submodules: false
|
||||
lfs: false,
|
||||
fetchDepth: 500
|
||||
- template: templates/e2e-install.yml
|
||||
parameters:
|
||||
nodeVersion: ${{ variables.nodeVersion }}
|
||||
npm_config_cache: ${{ variables.npm_config_cache }}
|
||||
- powershell: |
|
||||
npm install --global @cyclonedx/cyclonedx-npm
|
||||
cyclonedx-npm -o $(Build.ArtifactStagingDirectory)/bom/bom-e2e.xml --ignore-npm-errors --verbose
|
||||
displayName: Generate E2E Tests BOM
|
||||
workingDirectory: tests/Umbraco.Tests.AcceptanceTest
|
||||
- publish: $(Build.ArtifactStagingDirectory)/bom
|
||||
artifact: bom-e2e
|
||||
displayName: 'Publish E2E BOM'
|
||||
|
||||
- stage: Build_Docs
|
||||
condition: and(succeeded(), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.buildApiDocs}}))
|
||||
@@ -668,6 +720,34 @@ stages:
|
||||
ASPNETCORE_URLS: ${{ variables.ASPNETCORE_URLS }}
|
||||
DatabaseType: ${{ variables.DatabaseType }}
|
||||
|
||||
- stage: Dependency_Track
|
||||
displayName: Dependency Track
|
||||
dependsOn:
|
||||
- Build
|
||||
- E2E_BOM
|
||||
condition: and(succeeded(), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.uploadDependencyTrack}}))
|
||||
variables:
|
||||
# Determine Umbraco version based on whether it's a public release or not. If public release, use major version, else use full NuGet package version.
|
||||
umbracoVersion: $[ iif(eq(stageDependencies.Build.A.outputs['build.NBGV_PublicRelease'], 'True'), stageDependencies.Build.A.outputs['build.NBGV_VersionMajor'], stageDependencies.Build.A.outputs['build.NBGV_NuGetPackageVersion']) ]
|
||||
jobs:
|
||||
- template: templates/dependency-track.yml
|
||||
parameters:
|
||||
projectName: "Umbraco-CMS"
|
||||
umbracoVersion: $(umbracoVersion)
|
||||
projects:
|
||||
- name: "Backend"
|
||||
artifact: "bom-backend"
|
||||
bomFilePath: "bom-dotnet.xml"
|
||||
- name: "Login"
|
||||
artifact: "bom-backend"
|
||||
bomFilePath: "bom-login.xml"
|
||||
- name: "Backoffice"
|
||||
artifact: "bom-frontend"
|
||||
bomFilePath: "bom-backoffice.xml"
|
||||
- name: "E2E"
|
||||
artifact: "bom-e2e"
|
||||
bomFilePath: "bom-e2e.xml"
|
||||
|
||||
###############################################
|
||||
## Release
|
||||
###############################################
|
||||
@@ -874,3 +954,4 @@ stages:
|
||||
ContainerName: "$web"
|
||||
BlobPrefix: v$(umbracoMajorVersion)/ui-api
|
||||
CleanTargetBeforeCopy: true
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ steps:
|
||||
# Test
|
||||
- pwsh: ${{ parameters.testCommand }}
|
||||
displayName: Run Playwright tests
|
||||
continueOnError: true
|
||||
workingDirectory: tests/Umbraco.Tests.AcceptanceTest
|
||||
env:
|
||||
CI: true
|
||||
|
||||
@@ -26,38 +26,18 @@ steps:
|
||||
artifact: nupkg
|
||||
path: $(Agent.BuildDirectory)/app/nupkg
|
||||
|
||||
- task: NodeTool@0
|
||||
displayName: Use Node.js $(nodeVersion)
|
||||
inputs:
|
||||
versionSpec: $(nodeVersion)
|
||||
|
||||
- task: UseDotNet@2
|
||||
displayName: Use .NET SDK from global.json
|
||||
inputs:
|
||||
useGlobalJson: true
|
||||
|
||||
- pwsh: |
|
||||
"UMBRACO_USER_LOGIN=${{ parameters.PlaywrightUserEmail }}
|
||||
UMBRACO_USER_PASSWORD=${{ parameters.PlaywrightPassword }}
|
||||
URL=${{ parameters.ASPNETCORE_URLS }}
|
||||
STORAGE_STAGE_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/playwright/.auth/user.json
|
||||
CONSOLE_ERRORS_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/console-errors.json" | Out-File .env
|
||||
displayName: Generate .env
|
||||
workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest
|
||||
|
||||
# Cache and restore NPM packages
|
||||
- task: Cache@2
|
||||
displayName: Cache NPM packages
|
||||
inputs:
|
||||
key: 'npm_e2e | "$(Agent.OS)" | $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/package-lock.json'
|
||||
restoreKeys: |
|
||||
npm_e2e | "$(Agent.OS)"
|
||||
npm_e2e
|
||||
path: ${{ parameters.npm_config_cache }}
|
||||
|
||||
- script: npm ci --no-fund --no-audit --prefer-offline
|
||||
workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest
|
||||
displayName: Restore NPM packages
|
||||
- template: templates/e2e-install.yml
|
||||
parameters:
|
||||
nodeVersion: ${{ parameters.nodeVersion }}
|
||||
npm_config_cache: ${{ parameters.npm_config_cache }}
|
||||
PlaywrightUserEmail: ${{ parameters.PlaywrightUserEmail }}
|
||||
PlaywrightPassword: ${{ parameters.PlaywrightPassword }}
|
||||
ASPNETCORE_URLS: ${{ parameters.ASPNETCORE_URLS }}
|
||||
|
||||
# Install Template
|
||||
- pwsh: |
|
||||
|
||||
@@ -4,12 +4,11 @@ pr: none
|
||||
trigger: none
|
||||
|
||||
schedules:
|
||||
- cron: '0 0 * * *'
|
||||
displayName: Daily midnight build
|
||||
- cron: '0 6 * * *'
|
||||
displayName: Daily 6 AM build (v16/dev)
|
||||
branches:
|
||||
include:
|
||||
- v15/dev
|
||||
- main
|
||||
- v16/dev
|
||||
|
||||
parameters:
|
||||
- name: skipIntegrationTests
|
||||
@@ -294,7 +293,8 @@ stages:
|
||||
|
||||
- stage: DefaultConfigE2E
|
||||
displayName: Default Config E2E Tests
|
||||
dependsOn: Build
|
||||
dependsOn: Integration
|
||||
condition: always()
|
||||
variables:
|
||||
npm_config_cache: $(Pipeline.Workspace)/.npm_e2e
|
||||
# Enable console logging in Release mode
|
||||
@@ -475,7 +475,8 @@ stages:
|
||||
|
||||
- stage: AdditionalConfigE2E
|
||||
displayName: Additional Config E2E Tests
|
||||
dependsOn: Build
|
||||
dependsOn: DefaultConfigE2E
|
||||
condition: always()
|
||||
variables:
|
||||
npm_config_cache: $(Pipeline.Workspace)/.npm_e2e
|
||||
ASPNETCORE_URLS: https://localhost:44331
|
||||
@@ -526,6 +527,23 @@ stages:
|
||||
CONNECTIONSTRINGS__UMBRACODBDSN: Data Source=(localdb)\MSSQLLocalDB;AttachDbFilename=|DataDirectory|\Umbraco.mdf;Integrated Security=True
|
||||
CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.SqlClient
|
||||
additionalEnvironmentVariables: true
|
||||
# ExtensionRegistry
|
||||
WindowsExtensionRegistry:
|
||||
vmImage: "windows-latest"
|
||||
testFolder: "ExtensionRegistry"
|
||||
port: ''
|
||||
testCommand: "npx playwright test --project=extensionRegistry"
|
||||
CONNECTIONSTRINGS__UMBRACODBDSN: Data Source=(localdb)\MSSQLLocalDB;AttachDbFilename=|DataDirectory|\Umbraco.mdf;Integrated Security=True
|
||||
CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.SqlClient
|
||||
additionalEnvironmentVariables: false
|
||||
LinuxExtensionRegistry:
|
||||
vmImage: "ubuntu-latest"
|
||||
testFolder: "ExtensionRegistry"
|
||||
port: ''
|
||||
testCommand: "npx playwright test --project=extensionRegistry"
|
||||
CONNECTIONSTRINGS__UMBRACODBDSN: Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True
|
||||
CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.SqlClient
|
||||
additionalEnvironmentVariables: false
|
||||
pool:
|
||||
vmImage: $(vmImage)
|
||||
steps:
|
||||
|
||||
@@ -8,8 +8,8 @@ schedules:
|
||||
displayName: Daily midnight build
|
||||
branches:
|
||||
include:
|
||||
- v10/dev
|
||||
- v13/dev
|
||||
- v17/dev
|
||||
- main
|
||||
|
||||
steps:
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
parameters:
|
||||
- name: projectName
|
||||
type: string
|
||||
- name: umbracoVersion
|
||||
type: string
|
||||
- name: projects
|
||||
type: object
|
||||
|
||||
jobs:
|
||||
- job: Create_DT_Project
|
||||
displayName: Create Dependency Track Project
|
||||
steps:
|
||||
- checkout: none
|
||||
|
||||
- bash: |
|
||||
project_id=$(curl --no-progress-meter -H "X-Api-Key: $(DT_API_KEY)" "$(DT_API_URL)/v1/project/lookup?name=${{ parameters.projectName }}&version=${{ parameters.umbracoVersion }}" | jq -r '.uuid')
|
||||
if [ "$project_id" != "null" ] && [ -n "$project_id" ]; then
|
||||
echo "Project '${{ parameters.projectName }}' with version '${{ parameters.umbracoVersion }}' already exists (ID: $project_id)."
|
||||
else
|
||||
project_id=$(curl --no-progress-meter \
|
||||
-X PUT "$(DT_API_URL)/v1/project" \
|
||||
-H "X-Api-Key: $(DT_API_KEY)" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "${{ parameters.projectName }}", "version": "${{ parameters.umbracoVersion }}", "collectionLogic": "AGGREGATE_DIRECT_CHILDREN"}' \
|
||||
| jq -r '.uuid')
|
||||
if [ -z "$project_id" ] || [ "$project_id" == "null" ]; then
|
||||
echo "Failed to create project '${{ parameters.projectName }}' version '${{ parameters.umbracoVersion }}'."
|
||||
exit 1
|
||||
fi
|
||||
echo "Created project '${{ parameters.projectName }}' with version '${{ parameters.umbracoVersion }}' (ID: $project_id)."
|
||||
fi
|
||||
displayName: Ensure main project exists in Dependency Track
|
||||
|
||||
- ${{ each project in parameters.projects }}:
|
||||
- job:
|
||||
displayName: Upload ${{ project.name }} BOM
|
||||
dependsOn: Create_DT_Project
|
||||
steps:
|
||||
- checkout: none
|
||||
|
||||
- download: current
|
||||
artifact: ${{ project.artifact }}
|
||||
displayName: Download ${{ project.artifact }} artifact
|
||||
|
||||
- script: |
|
||||
curl --no-progress-meter --fail-with-body \
|
||||
-X POST "$(DT_API_URL)/v1/bom" \
|
||||
-H "X-Api-Key: $(DT_API_KEY)" \
|
||||
-H "Content-Type: multipart/form-data" \
|
||||
-F "autoCreate=true" \
|
||||
-F "projectName=${{ parameters.projectName }}-${{ project.name }}" \
|
||||
-F "projectVersion=${{ parameters.umbracoVersion }}" \
|
||||
-F "parentName=${{ parameters.projectName }}" \
|
||||
-F "parentVersion=${{ parameters.umbracoVersion }}" \
|
||||
-F "bom=@$(Pipeline.Workspace)/${{ project.artifact }}/${{ project.bomFilePath }}"
|
||||
displayName: Upload ${{ project.name }} BOM to Dependency Track
|
||||
@@ -0,0 +1,49 @@
|
||||
parameters:
|
||||
- name: nodeVersion
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
- name: npm_config_cache
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
- name: PlaywrightUserEmail
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
- name: PlaywrightPassword
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
- name: ASPNETCORE_URLS
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
steps:
|
||||
- task: NodeTool@0
|
||||
displayName: Use Node.js $(nodeVersion)
|
||||
inputs:
|
||||
versionSpec: $(nodeVersion)
|
||||
|
||||
- pwsh: |
|
||||
"UMBRACO_USER_LOGIN=${{ parameters.PlaywrightUserEmail }}
|
||||
UMBRACO_USER_PASSWORD=${{ parameters.PlaywrightPassword }}
|
||||
URL=${{ parameters.ASPNETCORE_URLS }}
|
||||
STORAGE_STAGE_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/playwright/.auth/user.json
|
||||
CONSOLE_ERRORS_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/console-errors.json" | Out-File .env
|
||||
displayName: Generate .env
|
||||
workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest
|
||||
|
||||
# Cache and restore NPM packages
|
||||
- task: Cache@2
|
||||
displayName: Cache NPM packages
|
||||
inputs:
|
||||
key: 'npm_e2e | "$(Agent.OS)" | $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/package-lock.json'
|
||||
restoreKeys: |
|
||||
npm_e2e | "$(Agent.OS)"
|
||||
npm_e2e
|
||||
path: ${{ parameters.npm_config_cache }}
|
||||
|
||||
- script: npm ci --no-fund --no-audit --prefer-offline
|
||||
workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest
|
||||
displayName: Restore NPM packages
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "9.0.100",
|
||||
"version": "9.0.306",
|
||||
"rollForward": "latestFeature",
|
||||
"allowPrerelease": false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Umbraco.Cms.Core.DeliveryApi;
|
||||
|
||||
namespace Umbraco.Cms.Api.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.Api.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;
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ public class ConfigureUmbracoSwaggerGenOptions : IConfigureOptions<SwaggerGenOpt
|
||||
swaggerGenOptions.DocInclusionPredicate((name, api) =>
|
||||
{
|
||||
if (api.ActionDescriptor is ControllerActionDescriptor controllerActionDescriptor
|
||||
&& controllerActionDescriptor.MethodInfo.HasMapToApiAttribute(name))
|
||||
&& controllerActionDescriptor.HasMapToApiAttribute(name))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OpenIddict.Server;
|
||||
using OpenIddict.Validation;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Web.Common.Security;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.DependencyInjection;
|
||||
|
||||
internal sealed class HideBackOfficeTokensHandler
|
||||
: IOpenIddictServerHandler<OpenIddictServerEvents.ApplyTokenResponseContext>,
|
||||
IOpenIddictServerHandler<OpenIddictServerEvents.ApplyAuthorizationResponseContext>,
|
||||
IOpenIddictServerHandler<OpenIddictServerEvents.ExtractTokenRequestContext>,
|
||||
IOpenIddictValidationHandler<OpenIddictValidationEvents.ProcessAuthenticationContext>,
|
||||
INotificationHandler<UserLogoutSuccessNotification>
|
||||
{
|
||||
private const string RedactedTokenValue = "[redacted]";
|
||||
private const string AccessTokenCookieKey = "__Host-umbAccessToken";
|
||||
private const string RefreshTokenCookieKey = "__Host-umbRefreshToken";
|
||||
private const string PkceCodeCookieKey = "__Host-umbPkceCode";
|
||||
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly IDataProtectionProvider _dataProtectionProvider;
|
||||
private readonly BackOfficeTokenCookieSettings _backOfficeTokenCookieSettings;
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
|
||||
public HideBackOfficeTokensHandler(
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IDataProtectionProvider dataProtectionProvider,
|
||||
IOptions<BackOfficeTokenCookieSettings> backOfficeTokenCookieSettings,
|
||||
IOptions<GlobalSettings> globalSettings)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_dataProtectionProvider = dataProtectionProvider;
|
||||
_backOfficeTokenCookieSettings = backOfficeTokenCookieSettings.Value;
|
||||
_globalSettings = globalSettings.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This is invoked when tokens (access and refresh tokens) are issued to a client. For the back-office client,
|
||||
/// we will intercept the response, write the tokens from the response into HTTP-only cookies, and redact the
|
||||
/// tokens from the response, so they are not exposed to the client.
|
||||
/// </summary>
|
||||
public ValueTask HandleAsync(OpenIddictServerEvents.ApplyTokenResponseContext context)
|
||||
{
|
||||
if (context.Request?.ClientId is not Constants.OAuthClientIds.BackOffice)
|
||||
{
|
||||
// Only ever handle the back-office client.
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
HttpContext httpContext = GetHttpContext();
|
||||
|
||||
if (context.Response.AccessToken is not null)
|
||||
{
|
||||
SetCookie(httpContext, AccessTokenCookieKey, context.Response.AccessToken);
|
||||
context.Response.AccessToken = RedactedTokenValue;
|
||||
}
|
||||
|
||||
if (context.Response.RefreshToken is not null)
|
||||
{
|
||||
SetCookie(httpContext, RefreshTokenCookieKey, context.Response.RefreshToken);
|
||||
context.Response.RefreshToken = RedactedTokenValue;
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This is invoked when a PKCE code is issued to the client. For the back-office client, we will intercept the
|
||||
/// response, write the PKCE code from the response into a HTTP-only cookie, and redact the code from the response,
|
||||
/// so it's not exposed to the client.
|
||||
/// </summary>
|
||||
public ValueTask HandleAsync(OpenIddictServerEvents.ApplyAuthorizationResponseContext context)
|
||||
{
|
||||
if (context.Request?.ClientId is not Constants.OAuthClientIds.BackOffice)
|
||||
{
|
||||
// Only ever handle the back-office client.
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
if (context.Response.Code is not null)
|
||||
{
|
||||
SetCookie(GetHttpContext(), PkceCodeCookieKey, context.Response.Code);
|
||||
context.Response.Code = RedactedTokenValue;
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This is invoked when requesting new tokens.
|
||||
/// </summary>
|
||||
public ValueTask HandleAsync(OpenIddictServerEvents.ExtractTokenRequestContext context)
|
||||
{
|
||||
if (context.Request?.ClientId != Constants.OAuthClientIds.BackOffice)
|
||||
{
|
||||
// Only ever handle the back-office client.
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
// Handle when the PKCE code is being exchanged for an access token.
|
||||
if (context.Request.Code == RedactedTokenValue
|
||||
&& TryGetCookie(PkceCodeCookieKey, out var code))
|
||||
{
|
||||
context.Request.Code = code;
|
||||
|
||||
// We won't need the PKCE cookie after this, let's remove it.
|
||||
RemoveCookie(GetHttpContext(), PkceCodeCookieKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
// PCKE codes should always be redacted. If we got here, someone might be trying to pass another PKCE
|
||||
// code. For security reasons, explicitly discard the code (if any) to be on the safe side.
|
||||
context.Request.Code = null;
|
||||
}
|
||||
|
||||
// Handle when a refresh token is being exchanged for a new access token.
|
||||
if (context.Request.RefreshToken == RedactedTokenValue
|
||||
&& TryGetCookie(RefreshTokenCookieKey, out var refreshToken))
|
||||
{
|
||||
context.Request.RefreshToken = refreshToken;
|
||||
}
|
||||
else
|
||||
{
|
||||
// If we got here, either the refresh token was not redacted, or nothing was found in the refresh token cookie.
|
||||
// If OpenIddict found a refresh token, it could be an old token that is potentially still valid. For security
|
||||
// reasons, we cannot accept that; at this point, we expect the refresh tokens to be explicitly redacted.
|
||||
context.Request.RefreshToken = null;
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// This is invoked when extracting the auth context for a client request.
|
||||
/// </summary>
|
||||
public ValueTask HandleAsync(OpenIddictValidationEvents.ProcessAuthenticationContext context)
|
||||
{
|
||||
// For the back-office client, this only happens when an access token is sent to the API.
|
||||
if (context.AccessToken != RedactedTokenValue)
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
if (TryGetCookie(AccessTokenCookieKey, out var accessToken))
|
||||
{
|
||||
context.AccessToken = accessToken;
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public void Handle(UserLogoutSuccessNotification notification)
|
||||
{
|
||||
HttpContext? context = _httpContextAccessor.HttpContext;
|
||||
if (context is null)
|
||||
{
|
||||
// For some reason there is no ambient HTTP context, so we can't clean up the cookies.
|
||||
// This is OK, because the tokens in the cookies have already been revoked at user sign-out,
|
||||
// so the cookie clean-up is mostly cosmetic.
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.Cookies.Delete(AccessTokenCookieKey);
|
||||
context.Response.Cookies.Delete(RefreshTokenCookieKey);
|
||||
}
|
||||
|
||||
private HttpContext GetHttpContext()
|
||||
=> _httpContextAccessor.GetRequiredHttpContext();
|
||||
|
||||
private void SetCookie(HttpContext httpContext, string key, string value)
|
||||
{
|
||||
var cookieValue = EncryptionHelper.Encrypt(value, _dataProtectionProvider);
|
||||
|
||||
RemoveCookie(httpContext, key);
|
||||
httpContext.Response.Cookies.Append(key, cookieValue, GetCookieOptions(httpContext));
|
||||
}
|
||||
|
||||
private void RemoveCookie(HttpContext httpContext, string key)
|
||||
=> httpContext.Response.Cookies.Delete(key, GetCookieOptions(httpContext));
|
||||
|
||||
private CookieOptions GetCookieOptions(HttpContext httpContext) =>
|
||||
new()
|
||||
{
|
||||
// Prevent the client-side scripts from accessing the cookie.
|
||||
HttpOnly = true,
|
||||
|
||||
// Mark the cookie as essential to the application, to enforce it despite any
|
||||
// data collection consent options. This aligns with how ASP.NET Core Identity
|
||||
// does when writing cookies for cookie authentication.
|
||||
IsEssential = true,
|
||||
|
||||
// Cookie path must be root for optimal security.
|
||||
Path = "/",
|
||||
|
||||
// For optimal security, the cooke must be secure. However, Umbraco allows for running development
|
||||
// environments over HTTP, so we need to take that into account here.
|
||||
// Thus, we will make the cookie secure if:
|
||||
// - HTTPS is explicitly enabled by config (default for production environments), or
|
||||
// - The current request is over HTTPS (meaning the environment supports it regardless of config).
|
||||
Secure = _globalSettings.UseHttps || httpContext.Request.IsHttps,
|
||||
|
||||
// SameSite is configurable (see BackOfficeTokenCookieSettings for defaults):
|
||||
SameSite = ParseSameSiteMode(_backOfficeTokenCookieSettings.SameSite),
|
||||
};
|
||||
|
||||
private bool TryGetCookie(string key, [NotNullWhen(true)] out string? value)
|
||||
{
|
||||
if (GetHttpContext().Request.Cookies.TryGetValue(key, out var cookieValue))
|
||||
{
|
||||
value = EncryptionHelper.Decrypt(cookieValue, _dataProtectionProvider);
|
||||
return true;
|
||||
}
|
||||
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static SameSiteMode ParseSameSiteMode(string sameSiteMode) =>
|
||||
Enum.TryParse(sameSiteMode, ignoreCase: true, out SameSiteMode result)
|
||||
? result
|
||||
: throw new ArgumentException($"The provided {nameof(sameSiteMode)} value could not be parsed into as SameSiteMode value.", nameof(sameSiteMode));
|
||||
}
|
||||
@@ -9,6 +9,7 @@ using Umbraco.Cms.Api.Common.Security;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
@@ -28,6 +29,11 @@ public static class UmbracoBuilderAuthExtensions
|
||||
|
||||
private static void ConfigureOpenIddict(IUmbracoBuilder builder)
|
||||
{
|
||||
// Optionally hide tokens from the back-office.
|
||||
var hideBackOfficeTokens = (builder.Config
|
||||
.GetSection(Constants.Configuration.ConfigBackOfficeTokenCookie)
|
||||
.Get<BackOfficeTokenCookieSettings>() ?? new BackOfficeTokenCookieSettings()).Enabled;
|
||||
|
||||
builder.Services.AddOpenIddict()
|
||||
// Register the OpenIddict server components.
|
||||
.AddServer(options =>
|
||||
@@ -113,6 +119,28 @@ public static class UmbracoBuilderAuthExtensions
|
||||
{
|
||||
configuration.UseSingletonHandler<ProcessRequestContextHandler>().SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.ResolveRequestUri.Descriptor.Order - 1);
|
||||
});
|
||||
|
||||
if (hideBackOfficeTokens)
|
||||
{
|
||||
options.AddEventHandler<OpenIddictServerEvents.ApplyTokenResponseContext>(configuration =>
|
||||
{
|
||||
configuration
|
||||
.UseSingletonHandler<HideBackOfficeTokensHandler>()
|
||||
.SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.ProcessJsonResponse<OpenIddictServerEvents.ApplyTokenResponseContext>.Descriptor.Order - 1);
|
||||
});
|
||||
options.AddEventHandler<OpenIddictServerEvents.ApplyAuthorizationResponseContext>(configuration =>
|
||||
{
|
||||
configuration
|
||||
.UseSingletonHandler<HideBackOfficeTokensHandler>()
|
||||
.SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.Authentication.ProcessQueryResponse.Descriptor.Order - 1);
|
||||
});
|
||||
options.AddEventHandler<OpenIddictServerEvents.ExtractTokenRequestContext>(configuration =>
|
||||
{
|
||||
configuration
|
||||
.UseSingletonHandler<HideBackOfficeTokensHandler>()
|
||||
.SetOrder(OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers.ExtractPostRequest<OpenIddictServerEvents.ExtractTokenRequestContext>.Descriptor.Order + 1);
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
// Register the OpenIddict validation components.
|
||||
@@ -137,9 +165,25 @@ public static class UmbracoBuilderAuthExtensions
|
||||
{
|
||||
configuration.UseSingletonHandler<ProcessRequestContextHandler>().SetOrder(OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers.ResolveRequestUri.Descriptor.Order - 1);
|
||||
});
|
||||
|
||||
if (hideBackOfficeTokens)
|
||||
{
|
||||
options.AddEventHandler<OpenIddictValidationEvents.ProcessAuthenticationContext>(configuration =>
|
||||
{
|
||||
configuration
|
||||
.UseSingletonHandler<HideBackOfficeTokensHandler>()
|
||||
// IMPORTANT: the handler must be AFTER the built-in query string handler, because the client-side SignalR library sometimes appends access tokens to the query string.
|
||||
.SetOrder(OpenIddict.Validation.AspNetCore.OpenIddictValidationAspNetCoreHandlers.ExtractAccessTokenFromQueryString.Descriptor.Order + 1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
builder.Services.AddRecurringBackgroundJob<OpenIddictCleanupJob>();
|
||||
builder.Services.ConfigureOptions<ConfigureOpenIddict>();
|
||||
|
||||
if (hideBackOfficeTokens)
|
||||
{
|
||||
builder.AddNotificationHandler<UserLogoutSuccessNotification, HideBackOfficeTokensHandler>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using Microsoft.AspNetCore.Mvc.Abstractions;
|
||||
using Umbraco.Cms.Api.Common.Attributes;
|
||||
using Umbraco.Cms.Api.Common.Configuration;
|
||||
|
||||
namespace Umbraco.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for <see cref="ActionDescriptor"/> to work with <see cref="MapToApiAttribute"/>.
|
||||
/// </summary>
|
||||
public static class ActionDescriptorApiCommonExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines whether the <see cref="ActionDescriptor"/> has a <see cref="MapToApiAttribute"/> with the specified API name.
|
||||
/// The check is made in runtime to support attributes added in runtime.
|
||||
/// </summary>
|
||||
/// <param name="actionDescriptor">The action descriptor to inspect.</param>
|
||||
/// <param name="apiName">The API name to check for.</param>
|
||||
/// <returns>
|
||||
/// <c>true</c> if the <see cref="MapToApiAttribute"/> is present and matches the specified API name,
|
||||
/// or if the attribute is not present and the API name matches the default API name; otherwise, <c>false</c>.
|
||||
/// </returns>
|
||||
public static bool HasMapToApiAttribute(this ActionDescriptor actionDescriptor, string apiName)
|
||||
{
|
||||
var value = actionDescriptor.GetMapToApiAttributeValue();
|
||||
|
||||
return value == apiName
|
||||
|| (value is null && apiName == DefaultApiConfiguration.ApiName);
|
||||
}
|
||||
|
||||
private static string? GetMapToApiAttributeValue(this ActionDescriptor actionDescriptor)
|
||||
{
|
||||
IEnumerable<MapToApiAttribute> mapToApiAttributes = actionDescriptor?.EndpointMetadata?.OfType<MapToApiAttribute>() ?? [];
|
||||
|
||||
return mapToApiAttributes.SingleOrDefault()?.ApiName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using Umbraco.Cms.Core.DeliveryApi;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,28 +35,35 @@ 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<IRequestSegmmentService, RequestSegmentService>();
|
||||
builder.Services.AddSingleton<IRequestSegmentService, RequestSegmentService>();
|
||||
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,25 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Api.Common.Rendering;
|
||||
using Umbraco.Cms.Core.DeliveryApi;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
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 +29,7 @@ internal sealed class RequestContextOutputExpansionStrategyV2 : IOutputExpansion
|
||||
|
||||
try
|
||||
{
|
||||
_expandProperties.Push(Node.Parse(toExpand));
|
||||
ExpandProperties.Push(Node.Parse(toExpand));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
@@ -76,7 +39,7 @@ internal sealed class RequestContextOutputExpansionStrategyV2 : IOutputExpansion
|
||||
|
||||
try
|
||||
{
|
||||
_includeProperties.Push(Node.Parse(toInclude));
|
||||
IncludeProperties.Push(Node.Parse(toInclude));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
@@ -84,102 +47,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 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<Title>Umbraco CMS - Delivery API</Title>
|
||||
<Description>Contains the presentation layer for the Umbraco CMS Delivery API.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<!--
|
||||
TODO: Fix and remove overrides:
|
||||
@@ -12,7 +11,6 @@
|
||||
-->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors),ASP0019,CS0618,CS0612</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Umbraco.Cms.Api.Common\Umbraco.Cms.Api.Common.csproj" />
|
||||
<ProjectReference Include="..\Umbraco.Web.Common\Umbraco.Web.Common.csproj" />
|
||||
|
||||
@@ -1,31 +1,60 @@
|
||||
using Asp.Versioning;
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Document;
|
||||
using Umbraco.Cms.Core.Actions;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Mapping;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Security.Authorization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Web.Common.Authorization;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.Document;
|
||||
|
||||
[ApiVersion("1.0")]
|
||||
public class DomainsController : DocumentControllerBase
|
||||
{
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IDomainService _domainService;
|
||||
private readonly IUmbracoMapper _umbracoMapper;
|
||||
|
||||
public DomainsController(IDomainService domainService, IUmbracoMapper umbracoMapper)
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public DomainsController(IAuthorizationService authorizationService, IDomainService domainService, IUmbracoMapper umbracoMapper)
|
||||
{
|
||||
_authorizationService = authorizationService;
|
||||
_domainService = domainService;
|
||||
_umbracoMapper = umbracoMapper;
|
||||
}
|
||||
|
||||
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
|
||||
public DomainsController(IDomainService domainService, IUmbracoMapper umbracoMapper)
|
||||
: this(
|
||||
StaticServiceProvider.Instance.GetRequiredService<IAuthorizationService>(),
|
||||
domainService,
|
||||
umbracoMapper)
|
||||
{
|
||||
}
|
||||
|
||||
[MapToApiVersion("1.0")]
|
||||
[HttpGet("{id:guid}/domains")]
|
||||
[ProducesResponseType(typeof(DomainsResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Domains(CancellationToken cancellationToken, Guid id)
|
||||
{
|
||||
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
|
||||
User,
|
||||
ContentPermissionResource.WithKeys(ActionBrowse.ActionLetter, id),
|
||||
AuthorizationPolicies.ContentPermissionByResource);
|
||||
|
||||
if (!authorizationResult.Succeeded)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
IDomain[] assignedDomains = (await _domainService.GetAssignedDomainsAsync(id, true))
|
||||
.OrderBy(d => d.SortOrder)
|
||||
.ToArray();
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
using Asp.Versioning;
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Api.Management.Factories;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Document;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Actions;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Mapping;
|
||||
using Umbraco.Cms.Core.Models.ContentEditing;
|
||||
using Umbraco.Cms.Core.Security.Authorization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
using Umbraco.Cms.Web.Common.Authorization;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.Document;
|
||||
@@ -15,17 +21,30 @@ namespace Umbraco.Cms.Api.Management.Controllers.Document;
|
||||
[ApiVersion("1.0")]
|
||||
public class UpdateDomainsController : DocumentControllerBase
|
||||
{
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IDomainService _domainService;
|
||||
private readonly IUmbracoMapper _umbracoMapper;
|
||||
private readonly IDomainPresentationFactory _domainPresentationFactory;
|
||||
|
||||
public UpdateDomainsController(IDomainService domainService, IUmbracoMapper umbracoMapper, IDomainPresentationFactory domainPresentationFactory)
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public UpdateDomainsController(IAuthorizationService authorizationService, IDomainService domainService, IUmbracoMapper umbracoMapper, IDomainPresentationFactory domainPresentationFactory)
|
||||
{
|
||||
_authorizationService = authorizationService;
|
||||
_domainService = domainService;
|
||||
_umbracoMapper = umbracoMapper;
|
||||
_domainPresentationFactory = domainPresentationFactory;
|
||||
}
|
||||
|
||||
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
|
||||
public UpdateDomainsController(IDomainService domainService, IUmbracoMapper umbracoMapper, IDomainPresentationFactory domainPresentationFactory)
|
||||
: this(
|
||||
StaticServiceProvider.Instance.GetRequiredService<IAuthorizationService>(),
|
||||
domainService,
|
||||
umbracoMapper,
|
||||
domainPresentationFactory)
|
||||
{
|
||||
}
|
||||
|
||||
[MapToApiVersion("1.0")]
|
||||
[HttpPut("{id:guid}/domains")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
@@ -37,6 +56,16 @@ public class UpdateDomainsController : DocumentControllerBase
|
||||
Guid id,
|
||||
UpdateDomainsRequestModel updateModel)
|
||||
{
|
||||
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
|
||||
User,
|
||||
ContentPermissionResource.WithKeys(ActionAssignDomain.ActionLetter, id),
|
||||
AuthorizationPolicies.ContentPermissionByResource);
|
||||
|
||||
if (!authorizationResult.Succeeded)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
DomainsUpdateModel domainsUpdateModel = _umbracoMapper.Map<DomainsUpdateModel>(updateModel)!;
|
||||
|
||||
Attempt<DomainUpdateResult, DomainOperationStatus> result = await _domainService.UpdateDomainsAsync(id, domainsUpdateModel);
|
||||
|
||||
+31
-1
@@ -1,33 +1,63 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Document;
|
||||
using Umbraco.Cms.Core.Actions;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Security.Authorization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Web.Common.Authorization;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.Document;
|
||||
|
||||
[ApiVersion("1.0")]
|
||||
public class UpdateNotificationsController : DocumentControllerBase
|
||||
{
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IContentEditingService _contentEditingService;
|
||||
private readonly INotificationService _notificationService;
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
|
||||
public UpdateNotificationsController(IContentEditingService contentEditingService, INotificationService notificationService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public UpdateNotificationsController(IAuthorizationService authorizationService, IContentEditingService contentEditingService, INotificationService notificationService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
|
||||
{
|
||||
_authorizationService = authorizationService;
|
||||
_contentEditingService = contentEditingService;
|
||||
_notificationService = notificationService;
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
}
|
||||
|
||||
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")]
|
||||
public UpdateNotificationsController(IContentEditingService contentEditingService, INotificationService notificationService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
|
||||
: this(
|
||||
StaticServiceProvider.Instance.GetRequiredService<IAuthorizationService>(),
|
||||
contentEditingService,
|
||||
notificationService,
|
||||
backOfficeSecurityAccessor)
|
||||
{
|
||||
}
|
||||
|
||||
[MapToApiVersion("1.0")]
|
||||
[HttpPut("{id:guid}/notifications")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> UpdateNotifications(CancellationToken cancellationToken, Guid id, UpdateDocumentNotificationsRequestModel updateModel)
|
||||
{
|
||||
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
|
||||
User,
|
||||
ContentPermissionResource.WithKeys(ActionBrowse.ActionLetter, id),
|
||||
AuthorizationPolicies.ContentPermissionByResource);
|
||||
|
||||
if (!authorizationResult.Succeeded)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
IContent? content = await _contentEditingService.GetAsync(id);
|
||||
if (content == null)
|
||||
{
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
using Asp.Versioning;
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Api.Management.Controllers.UserGroup;
|
||||
using Umbraco.Cms.Api.Management.Routing;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.User;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Security.Authorization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
@@ -25,11 +28,26 @@ public class UpdateUserGroupsUserController : UserGroupControllerBase
|
||||
{
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IUserGroupService _userGroupService;
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
|
||||
public UpdateUserGroupsUserController(IAuthorizationService authorizationService, IUserGroupService userGroupService)
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public UpdateUserGroupsUserController(
|
||||
IAuthorizationService authorizationService,
|
||||
IUserGroupService userGroupService,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
|
||||
{
|
||||
_authorizationService = authorizationService;
|
||||
_userGroupService = userGroupService;
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
}
|
||||
|
||||
[Obsolete("Please use the constructor accepting all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
public UpdateUserGroupsUserController(IAuthorizationService authorizationService, IUserGroupService userGroupService)
|
||||
: this(
|
||||
authorizationService,
|
||||
userGroupService,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IBackOfficeSecurityAccessor>())
|
||||
{
|
||||
}
|
||||
|
||||
[HttpPost("set-user-groups")]
|
||||
@@ -51,7 +69,8 @@ public class UpdateUserGroupsUserController : UserGroupControllerBase
|
||||
|
||||
Attempt<UserGroupOperationStatus> result = await _userGroupService.UpdateUserGroupsOnUsersAsync(
|
||||
requestModel.UserGroupIds.Select(x => x.Id).ToHashSet(),
|
||||
requestModel.UserIds.Select(x => x.Id).ToHashSet());
|
||||
requestModel.UserIds.Select(x => x.Id).ToHashSet(),
|
||||
CurrentUserKey(_backOfficeSecurityAccessor));
|
||||
|
||||
return result.Success
|
||||
? Ok()
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
using Umbraco.Cms.Api.Management.Factories;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Umbraco.Cms.Api.Common.Accessors;
|
||||
using Umbraco.Cms.Api.Common.Rendering;
|
||||
using Umbraco.Cms.Api.Management.Factories;
|
||||
using Umbraco.Cms.Api.Management.Mapping.Webhook;
|
||||
using Umbraco.Cms.Core.DeliveryApi;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
@@ -12,6 +16,11 @@ internal static class WebhooksBuilderExtensions
|
||||
builder.Services.AddUnique<IWebhookPresentationFactory, WebhookPresentationFactory>();
|
||||
builder.AddMapDefinition<WebhookEventMapDefinition>();
|
||||
|
||||
// We have to use TryAdd here, as if they are registered by the delivery API, we don't want to register them
|
||||
// Delivery API will also overwrite these IF it is enabled.
|
||||
builder.Services.TryAddScoped<IOutputExpansionStrategy, ElementOnlyOutputExpansionStrategy>();
|
||||
builder.Services.TryAddSingleton<IOutputExpansionStrategyAccessor, RequestContextOutputExpansionStrategyAccessor>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Text;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Html;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Umbraco.Cms.Core;
|
||||
@@ -24,29 +24,18 @@ public static class HtmlHelperBackOfficeExtensions
|
||||
IBackOfficePathGenerator backOfficePathGenerator,
|
||||
IPackageManifestService packageManifestService)
|
||||
{
|
||||
try
|
||||
{
|
||||
PackageManifestImportmap packageImports = await packageManifestService.GetPackageManifestImportmapAsync();
|
||||
PackageManifestImportmap packageImports = await packageManifestService.GetPackageManifestImportmapAsync();
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("""<script type="importmap">""");
|
||||
sb.AppendLine(jsonSerializer.Serialize(packageImports));
|
||||
sb.AppendLine("</script>");
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("""<script type="importmap">""");
|
||||
sb.AppendLine(jsonSerializer.Serialize(packageImports));
|
||||
sb.AppendLine("</script>");
|
||||
|
||||
// Inject the BackOffice cache buster into the import string to handle BackOffice assets
|
||||
var importmapScript = sb.ToString()
|
||||
.Replace(backOfficePathGenerator.BackOfficeVirtualDirectory, backOfficePathGenerator.BackOfficeAssetsPath)
|
||||
.Replace(Constants.Web.CacheBusterToken, backOfficePathGenerator.BackOfficeCacheBustHash);
|
||||
// Inject the BackOffice cache buster into the import string to handle BackOffice assets
|
||||
var importmapScript = sb.ToString()
|
||||
.Replace(backOfficePathGenerator.BackOfficeVirtualDirectory, backOfficePathGenerator.BackOfficeAssetsPath)
|
||||
.Replace(Constants.Web.CacheBusterToken, backOfficePathGenerator.BackOfficeCacheBustHash);
|
||||
|
||||
return html.Raw(importmapScript);
|
||||
}
|
||||
catch (NotSupportedException ex)
|
||||
{
|
||||
throw new NotSupportedException("Failed to serialize the BackOffice import map", ex);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception("Failed to generate the BackOffice import map", ex);
|
||||
}
|
||||
return html.Raw(importmapScript);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Umbraco.Cms.Core.Mapping;
|
||||
using Umbraco.Cms.Core.Extensions;
|
||||
using Umbraco.Cms.Core.Mapping;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Api.Management.Models;
|
||||
@@ -23,8 +24,7 @@ public class DictionaryPresentationFactory : IDictionaryPresentationFactory
|
||||
{
|
||||
DictionaryItemResponseModel dictionaryResponseModel = _umbracoMapper.Map<DictionaryItemResponseModel>(dictionaryItem)!;
|
||||
|
||||
var validLanguageIsoCodes = (await _languageService.GetAllAsync())
|
||||
.Select(language => language.IsoCode)
|
||||
var validLanguageIsoCodes = (await _languageService.GetAllIsoCodesAsync())
|
||||
.ToArray();
|
||||
IDictionaryTranslation[] validTranslations = dictionaryItem.Translations
|
||||
.Where(t => validLanguageIsoCodes.Contains(t.LanguageIsoCode))
|
||||
|
||||
@@ -211,11 +211,7 @@ public class UserGroupPresentationFactory : IUserGroupPresentationFactory
|
||||
|
||||
private async Task<Attempt<IEnumerable<string>, UserGroupOperationStatus>> MapLanguageIdsToIsoCodeAsync(IEnumerable<int> ids)
|
||||
{
|
||||
IEnumerable<ILanguage> languages = await _languageService.GetAllAsync();
|
||||
string[] isoCodes = languages
|
||||
.Where(x => ids.Contains(x.Id))
|
||||
.Select(x => x.IsoCode)
|
||||
.ToArray();
|
||||
string[] isoCodes = await _languageService.GetIsoCodesByIdsAsync(ids.ToArray());
|
||||
|
||||
// if a language id does not exist, it simply not returned.
|
||||
// We do this so we don't have to clean up user group data when deleting languages and to make it easier to restore accidentally removed languages
|
||||
|
||||
@@ -12,6 +12,7 @@ internal static class DocumentVariantStateHelper
|
||||
culture,
|
||||
content.Edited,
|
||||
content.Published,
|
||||
content.Trashed,
|
||||
content.AvailableCultures,
|
||||
content.EditedCultures ?? Enumerable.Empty<string>(),
|
||||
content.PublishedCultures);
|
||||
@@ -22,17 +23,23 @@ internal static class DocumentVariantStateHelper
|
||||
culture,
|
||||
content.Edited,
|
||||
content.Published,
|
||||
content.Trashed,
|
||||
content.CultureNames.Keys,
|
||||
content.EditedCultures,
|
||||
content.PublishedCultures);
|
||||
|
||||
private static DocumentVariantState GetState(IEntity entity, string? culture, bool edited, bool published, IEnumerable<string> availableCultures, IEnumerable<string> editedCultures, IEnumerable<string> publishedCultures)
|
||||
private static DocumentVariantState GetState(IEntity entity, string? culture, bool edited, bool published, bool trashed, IEnumerable<string> availableCultures, IEnumerable<string> editedCultures, IEnumerable<string> publishedCultures)
|
||||
{
|
||||
if (entity.Id <= 0 || (culture is not null && availableCultures.Contains(culture) is false))
|
||||
{
|
||||
return DocumentVariantState.NotCreated;
|
||||
}
|
||||
|
||||
if (trashed)
|
||||
{
|
||||
return DocumentVariantState.Trashed;
|
||||
}
|
||||
|
||||
var isDraft = published is false ||
|
||||
(culture != null && publishedCultures.Contains(culture) is false);
|
||||
if (isDraft)
|
||||
|
||||
@@ -11098,6 +11098,75 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/recycle-bin/document/siblings": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Document"
|
||||
],
|
||||
"operationId": "GetRecycleBinDocumentSiblings",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "target",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "before",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "after",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "dataTypeId",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SubsetDocumentRecycleBinItemResponseModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "The resource is protected and requires an authentication token"
|
||||
},
|
||||
"403": {
|
||||
"description": "The authenticated user does not have access to this resource"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Backoffice-User": [ ]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/tree/document/ancestors": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -18458,6 +18527,75 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/recycle-bin/media/siblings": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Media"
|
||||
],
|
||||
"operationId": "GetRecycleBinMediaSiblings",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "target",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "before",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "after",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "dataTypeId",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SubsetMediaRecycleBinItemResponseModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "The resource is protected and requires an authentication token"
|
||||
},
|
||||
"403": {
|
||||
"description": "The authenticated user does not have access to this resource"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Backoffice-User": [ ]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/tree/media/ancestors": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -20207,6 +20345,67 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/tree/member-type/siblings": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Member Type"
|
||||
],
|
||||
"operationId": "GetTreeMemberTypeSiblings",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "target",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "before",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "after",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SubsetMemberTypeTreeItemResponseModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "The resource is protected and requires an authentication token"
|
||||
},
|
||||
"403": {
|
||||
"description": "The authenticated user does not have access to this resource"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Backoffice-User": [ ]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/filter/member": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -23625,6 +23824,66 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/tree/partial-view/siblings": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Partial View"
|
||||
],
|
||||
"operationId": "GetTreePartialViewSiblings",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "path",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "before",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "after",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SubsetFileSystemTreeItemPresentationModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "The resource is protected and requires an authentication token"
|
||||
},
|
||||
"403": {
|
||||
"description": "The authenticated user does not have access to this resource"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Backoffice-User": [ ]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/preview": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
@@ -25615,6 +25874,66 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/tree/script/siblings": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Script"
|
||||
],
|
||||
"operationId": "GetTreeScriptSiblings",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "path",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "before",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "after",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SubsetFileSystemTreeItemPresentationModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "The resource is protected and requires an authentication token"
|
||||
},
|
||||
"403": {
|
||||
"description": "The authenticated user does not have access to this resource"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Backoffice-User": [ ]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/searcher": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -27701,6 +28020,66 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/tree/stylesheet/siblings": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Stylesheet"
|
||||
],
|
||||
"operationId": "GetTreeStylesheetSiblings",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "path",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "before",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "after",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SubsetFileSystemTreeItemPresentationModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "The resource is protected and requires an authentication token"
|
||||
},
|
||||
"403": {
|
||||
"description": "The authenticated user does not have access to this resource"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Backoffice-User": [ ]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/tag": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -29711,6 +30090,92 @@
|
||||
"Backoffice-User": [ ]
|
||||
}
|
||||
]
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"User Data"
|
||||
],
|
||||
"operationId": "DeleteUserDataById",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UserDataOperationStatusModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UserDataOperationStatusModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "The resource is protected and requires an authentication token"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Backoffice-User": [ ]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/filter/user-group": {
|
||||
@@ -37985,7 +38450,8 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"allowNonExistingSegmentsCreation": {
|
||||
"type": "boolean"
|
||||
"type": "boolean",
|
||||
"deprecated": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -39254,7 +39720,8 @@
|
||||
"NotCreated",
|
||||
"Draft",
|
||||
"Published",
|
||||
"PublishedPendingChanges"
|
||||
"PublishedPendingChanges",
|
||||
"Trashed"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
@@ -39749,7 +40216,7 @@
|
||||
},
|
||||
"actionParameters": {
|
||||
"type": "object",
|
||||
"additionalProperties": { },
|
||||
"additionalProperties": {},
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
@@ -40374,7 +40841,7 @@
|
||||
},
|
||||
"extensions": {
|
||||
"type": "array",
|
||||
"items": { }
|
||||
"items": {}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -44211,7 +44678,7 @@
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": { }
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"ProblemDetailsBuilderModel": {
|
||||
"type": "object",
|
||||
@@ -45374,6 +45841,35 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SubsetDocumentRecycleBinItemResponseModel": {
|
||||
"required": [
|
||||
"items",
|
||||
"totalAfter",
|
||||
"totalBefore"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"totalBefore": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"totalAfter": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/DocumentRecycleBinItemResponseModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SubsetDocumentTreeItemResponseModel": {
|
||||
"required": [
|
||||
"items",
|
||||
@@ -45432,6 +45928,64 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SubsetFileSystemTreeItemPresentationModel": {
|
||||
"required": [
|
||||
"items",
|
||||
"totalAfter",
|
||||
"totalBefore"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"totalBefore": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"totalAfter": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/FileSystemTreeItemPresentationModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SubsetMediaRecycleBinItemResponseModel": {
|
||||
"required": [
|
||||
"items",
|
||||
"totalAfter",
|
||||
"totalBefore"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"totalBefore": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"totalAfter": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/MediaRecycleBinItemResponseModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SubsetMediaTreeItemResponseModel": {
|
||||
"required": [
|
||||
"items",
|
||||
@@ -45490,6 +46044,35 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SubsetMemberTypeTreeItemResponseModel": {
|
||||
"required": [
|
||||
"items",
|
||||
"totalAfter",
|
||||
"totalBefore"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"totalBefore": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"totalAfter": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/MemberTypeTreeItemResponseModel"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SubsetNamedEntityTreeItemResponseModel": {
|
||||
"required": [
|
||||
"items",
|
||||
|
||||
@@ -75,9 +75,12 @@ public abstract class FileSystemTreeServiceBase : IFileSystemTreeService
|
||||
|
||||
public string[] GetFiles(string path) => FileSystem
|
||||
.GetFiles(path)
|
||||
.Where(FilterFile)
|
||||
.OrderBy(file => file)
|
||||
.ToArray();
|
||||
|
||||
protected virtual bool FilterFile(string file) => true;
|
||||
|
||||
public bool DirectoryHasChildren(string path)
|
||||
=> FileSystem.GetFiles(path).Any() || FileSystem.GetDirectories(path).Any();
|
||||
|
||||
|
||||
@@ -11,4 +11,6 @@ public class PartialViewTreeService : FileSystemTreeServiceBase, IPartialViewTre
|
||||
public PartialViewTreeService(FileSystems fileSystems) =>
|
||||
_partialViewFileSystem = fileSystems.PartialViewsFileSystem ??
|
||||
throw new ArgumentException("Missing partial views file system", nameof(fileSystems));
|
||||
|
||||
protected override bool FilterFile(string file) => file.ToLowerInvariant().EndsWith(".cshtml");
|
||||
}
|
||||
|
||||
@@ -11,4 +11,6 @@ public class ScriptTreeService : FileSystemTreeServiceBase, IScriptTreeService
|
||||
public ScriptTreeService(FileSystems fileSystems) =>
|
||||
_scriptFileSystem = fileSystems.ScriptsFileSystem ??
|
||||
throw new ArgumentException("Missing partial views file system", nameof(fileSystems));
|
||||
|
||||
protected override bool FilterFile(string file) => file.ToLowerInvariant().EndsWith(".js");
|
||||
}
|
||||
|
||||
@@ -10,5 +10,7 @@ public class StyleSheetTreeService : FileSystemTreeServiceBase, IStyleSheetTreeS
|
||||
|
||||
public StyleSheetTreeService(FileSystems fileSystems) =>
|
||||
_scriptFileSystem = fileSystems.StylesheetsFileSystem ??
|
||||
throw new ArgumentException("Missing partial views file system", nameof(fileSystems));
|
||||
throw new ArgumentException("Missing stylesheets file system", nameof(fileSystems));
|
||||
|
||||
protected override bool FilterFile(string file) => file.ToLowerInvariant().EndsWith(".css");
|
||||
}
|
||||
|
||||
+1
@@ -8,5 +8,6 @@ public class DocumentConfigurationResponseModel
|
||||
|
||||
public required bool AllowEditInvariantFromNonDefault { get; set; }
|
||||
|
||||
[Obsolete("This functionality will be moved to a client-side extension. Scheduled for removal in V19.")]
|
||||
public required bool AllowNonExistingSegmentsCreation { get; set; }
|
||||
}
|
||||
|
||||
@@ -24,4 +24,9 @@ public enum DocumentVariantState
|
||||
/// The item is published and there are pending changes
|
||||
/// </summary>
|
||||
PublishedPendingChanges = 4,
|
||||
|
||||
/// <summary>
|
||||
/// The item is in the recycle bin
|
||||
/// </summary>
|
||||
Trashed = 5,
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<PropertyGroup>
|
||||
<!--
|
||||
TODO: Fix and remove overrides:
|
||||
[SA1405] Debug assret message text
|
||||
[SA1405] Debug assert message text
|
||||
[SA1121] resolve hiding inherited members
|
||||
[SA1117] remove async or make method synchronous
|
||||
[IDE1006] fix naming rule violation
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<Title>Umbraco CMS - Persistence - SQLite</Title>
|
||||
<Description>Adds support for SQLite to Umbraco CMS.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
<PropertyGroup>
|
||||
<!--
|
||||
TODO: Fix and remove overrides:
|
||||
@@ -11,7 +11,6 @@
|
||||
-->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors),CS0114</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
<IncludeBuildOutput>false</IncludeBuildOutput>
|
||||
<IncludeSymbols>false</IncludeSymbols>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<!--
|
||||
TODO: Fix and remove overrides:
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace Umbraco.Cms.Core.Configuration.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Typed configuration options for back-office token cookie settings.
|
||||
/// </summary>
|
||||
[UmbracoOptions(Constants.Configuration.ConfigBackOfficeTokenCookie)]
|
||||
[Obsolete("This will be replaced with a different authentication scheme. Scheduled for removal in Umbraco 18.")]
|
||||
public class BackOfficeTokenCookieSettings
|
||||
{
|
||||
private const bool StaticEnabled = false;
|
||||
|
||||
private const string StaticSameSite = "Strict";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to enable access and refresh tokens in cookies.
|
||||
/// </summary>
|
||||
[DefaultValue(StaticEnabled)]
|
||||
[Obsolete("This is only configurable in Umbraco 16. Scheduled for removal in Umbraco 17.")]
|
||||
public bool Enabled { get; set; } = StaticEnabled;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the cookie SameSite configuration.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Valid values are "Unspecified", "None", "Lax" and "Strict" (default).
|
||||
/// </remarks>
|
||||
[DefaultValue(StaticSameSite)]
|
||||
public string SameSite { get; set; } = StaticSameSite;
|
||||
}
|
||||
@@ -181,6 +181,11 @@ public class GlobalSettings
|
||||
/// </summary>
|
||||
public bool IsSmtpServerConfigured => !string.IsNullOrWhiteSpace(Smtp?.Host);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether SMTP expiry is configured.
|
||||
/// </summary>
|
||||
public bool IsSmtpExpiryConfigured => Smtp?.EmailExpiration != null && Smtp?.EmailExpiration.HasValue == true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether there is a physical pickup directory configured.
|
||||
/// </summary>
|
||||
|
||||
@@ -34,6 +34,9 @@ public class SecuritySettings
|
||||
internal const string StaticAuthorizeCallbackLogoutPathName = "/umbraco/logout";
|
||||
internal const string StaticAuthorizeCallbackErrorPathName = "/umbraco/error";
|
||||
|
||||
internal const string StaticPasswordResetEmailExpiry = "01:00:00";
|
||||
internal const string StaticUserInviteEmailExpiry = "3.00:00:00";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to keep the user logged in.
|
||||
/// </summary>
|
||||
@@ -159,4 +162,16 @@ public class SecuritySettings
|
||||
/// </summary>
|
||||
[DefaultValue(StaticAuthorizeCallbackErrorPathName)]
|
||||
public string AuthorizeCallbackErrorPathName { get; set; } = StaticAuthorizeCallbackErrorPathName;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the expiry time for password reset emails.
|
||||
/// </summary>
|
||||
[DefaultValue(StaticPasswordResetEmailExpiry)]
|
||||
public TimeSpan PasswordResetEmailExpiry { get; set; } = TimeSpan.Parse(StaticPasswordResetEmailExpiry);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the expiry time for user invite emails.
|
||||
/// </summary>
|
||||
[DefaultValue(StaticUserInviteEmailExpiry)]
|
||||
public TimeSpan UserInviteEmailExpiry { get; set; } = TimeSpan.Parse(StaticUserInviteEmailExpiry);
|
||||
}
|
||||
|
||||
@@ -19,5 +19,6 @@ public class SegmentSettings
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the creation of non-existing segments is allowed.
|
||||
/// </summary>
|
||||
[Obsolete("This functionality will be moved to a client-side extension. Scheduled for removal in V19.")]
|
||||
public bool AllowCreation { get; set; } = StaticAllowCreation;
|
||||
}
|
||||
|
||||
@@ -96,4 +96,9 @@ public class SmtpSettings : ValidatableEntryBase
|
||||
/// Gets or sets a value for the SMTP password.
|
||||
/// </summary>
|
||||
public string? Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value for the time until an email expires.
|
||||
/// </summary>
|
||||
public TimeSpan? EmailExpiration { get; set; }
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ public static partial class Constants
|
||||
public const string ConfigWebhook = ConfigPrefix + "Webhook";
|
||||
public const string ConfigWebhookPayloadType = ConfigWebhook + ":PayloadType";
|
||||
public const string ConfigCache = ConfigPrefix + "Cache";
|
||||
public const string ConfigBackOfficeTokenCookie = ConfigSecurity + ":BackOfficeTokenCookie";
|
||||
|
||||
public static class NamedOptions
|
||||
{
|
||||
|
||||
@@ -45,7 +45,7 @@ public abstract class ApiContentBuilderBase<T>
|
||||
|
||||
public virtual T? Build(IPublishedContent content)
|
||||
{
|
||||
IApiContentRoute? route = ApiContentRouteBuilder.Build(content);
|
||||
IApiContentRoute? route = ApiContentRouteBuilder.Build(content, VariationContextAccessor.VariationContext?.Culture);
|
||||
if (route is null)
|
||||
{
|
||||
return default;
|
||||
|
||||
@@ -86,7 +86,8 @@ public static partial class UmbracoBuilderExtensions
|
||||
.AddUmbracoOptions<HelpPageSettings>()
|
||||
.AddUmbracoOptions<DataTypesSettings>()
|
||||
.AddUmbracoOptions<WebhookSettings>()
|
||||
.AddUmbracoOptions<CacheSettings>();
|
||||
.AddUmbracoOptions<CacheSettings>()
|
||||
.AddUmbracoOptions<BackOfficeTokenCookieSettings>();
|
||||
|
||||
// Configure connection string and ensure it's updated when the configuration changes
|
||||
builder.Services.AddSingleton<IConfigureOptions<ConnectionStrings>, ConfigureConnectionStrings>();
|
||||
|
||||
@@ -81,46 +81,23 @@ public class UserEditorAuthorizationHelper
|
||||
return Attempt<string?>.Succeed();
|
||||
}
|
||||
|
||||
// d) a non-admin user can remove any groups but can only add groups they themselves belong to
|
||||
if (userGroupAliases != null)
|
||||
{
|
||||
var savingGroupAliases = userGroupAliases.ToArray();
|
||||
var existingGroupAliases = savingUser == null
|
||||
IEnumerable<string> requestedGroupAliases = userGroupAliases.ToArray();
|
||||
IEnumerable<string> existingGroupAliases = savingUser == null
|
||||
? []
|
||||
: savingUser.Groups.Select(x => x.Alias).ToArray();
|
||||
: savingUser.Groups.Select(x => x.Alias);
|
||||
IEnumerable<string> performingUserGroupAliases = currentUser?.Groups.Select(x => x.Alias) ?? Enumerable.Empty<string>();
|
||||
|
||||
IEnumerable<string> addedGroupAliases = savingGroupAliases.Except(existingGroupAliases);
|
||||
IReadOnlyList<string> unauthorized = UserGroupAssignmentAuthorization
|
||||
.GetUnauthorizedGroupAssignments(performingUserGroupAliases, requestedGroupAliases, existingGroupAliases);
|
||||
|
||||
// As we know the current user is not admin, it is only allowed to use groups that the user do have themselves.
|
||||
var savingGroupAliasesNotAllowed = addedGroupAliases
|
||||
.Except(currentUser?.Groups.Select(x => x.Alias) ?? Enumerable.Empty<string>()).ToArray();
|
||||
if (savingGroupAliasesNotAllowed.Any())
|
||||
if (unauthorized.Count > 0)
|
||||
{
|
||||
return Attempt.Fail("Cannot assign the group(s) '" + string.Join(", ", savingGroupAliasesNotAllowed) +
|
||||
return Attempt.Fail("Cannot assign the group(s) '" + string.Join(", ", unauthorized) +
|
||||
"', the current user is not part of them or admin");
|
||||
}
|
||||
|
||||
// only validate any groups that have changed.
|
||||
// a non-admin user can remove groups and add groups that they have access to
|
||||
// but they cannot add a group that they do not have access to or that grants them
|
||||
// path or section access that they don't have access to.
|
||||
var newGroups = savingUser == null
|
||||
? savingGroupAliases
|
||||
: savingGroupAliases.Except(savingUser.Groups.Select(x => x.Alias)).ToArray();
|
||||
|
||||
var userGroupsChanged = savingUser != null && newGroups.Length > 0;
|
||||
|
||||
if (userGroupsChanged)
|
||||
{
|
||||
// d) A user cannot assign a group to another user that they do not belong to
|
||||
var currentUserGroups = currentUser?.Groups.Select(x => x.Alias).ToArray();
|
||||
foreach (var group in newGroups)
|
||||
{
|
||||
if (currentUserGroups?.Contains(group) == false)
|
||||
{
|
||||
return Attempt.Fail("Cannot assign the group " + group + ", the current user is not a member");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Attempt<string?>.Succeed();
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using Umbraco.Cms.Core.Services;
|
||||
|
||||
namespace Umbraco.Cms.Core.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for <see cref="ILanguageService"/>.
|
||||
/// </summary>
|
||||
public static class LanguageServiceExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves all ISO codes of all languages.
|
||||
/// </summary>
|
||||
/// <param name="service">The <see cref="ILanguageService"/>.</param>
|
||||
/// <returns>A collection of language ISO codes.</returns>
|
||||
public static async Task<IEnumerable<string>> GetAllIsoCodesAsync(this ILanguageService service) => (await service.GetAllAsync()).Select(x => x.IsoCode);
|
||||
}
|
||||
@@ -74,7 +74,7 @@ public class EmailNotificationMethod : NotificationMethodBase
|
||||
var subject = _textService?.Localize("healthcheck", "scheduledHealthCheckEmailSubject", new[] { host });
|
||||
|
||||
EmailMessage mailMessage = CreateMailMessage(subject, message);
|
||||
Task? task = _emailSender?.SendAsync(mailMessage, Constants.Web.EmailTypes.HealthCheck);
|
||||
Task? task = _emailSender?.SendAsync(mailMessage, Constants.Web.EmailTypes.HealthCheck, false, null);
|
||||
if (task is not null)
|
||||
{
|
||||
await task;
|
||||
|
||||
@@ -2,7 +2,7 @@ using System.Text.RegularExpressions;
|
||||
|
||||
namespace Umbraco.Cms.Core.IO;
|
||||
|
||||
internal sealed class ShadowFileSystem : IFileSystem
|
||||
internal sealed partial class ShadowFileSystem : IFileSystem
|
||||
{
|
||||
private readonly IFileSystem _sfs;
|
||||
|
||||
@@ -392,14 +392,28 @@ internal sealed class ShadowFileSystem : IFileSystem
|
||||
}
|
||||
|
||||
// copied from System.Web.Util.Wildcard internal
|
||||
internal sealed class WildcardExpression
|
||||
internal sealed partial class WildcardExpression
|
||||
{
|
||||
private static readonly Regex MetaRegex = new("[\\+\\{\\\\\\[\\|\\(\\)\\.\\^\\$]");
|
||||
private static readonly Regex QuestRegex = new("\\?");
|
||||
private static readonly Regex StarRegex = new("\\*");
|
||||
private static readonly Regex CommaRegex = new(",");
|
||||
private static readonly Regex SlashRegex = new("(?=/)");
|
||||
private static readonly Regex BackslashRegex = new("(?=[\\\\:])");
|
||||
private static readonly Regex MetaRegex = GetMetaRegex();
|
||||
|
||||
[GeneratedRegex("[\\+\\{\\\\\\[\\|\\(\\)\\.\\^\\$]")]
|
||||
private static partial Regex GetMetaRegex();
|
||||
|
||||
private static readonly Regex QuestRegex = GetQuestRegex();
|
||||
|
||||
[GeneratedRegex("\\?")]
|
||||
private static partial Regex GetQuestRegex();
|
||||
|
||||
private static readonly Regex StarRegex = GetStarRegex();
|
||||
|
||||
[GeneratedRegex("\\*")]
|
||||
private static partial Regex GetStarRegex();
|
||||
|
||||
private static readonly Regex CommaRegex = GetCommaRegex();
|
||||
|
||||
[GeneratedRegex(",")]
|
||||
private static partial Regex GetCommaRegex();
|
||||
|
||||
private readonly bool _caseInsensitive;
|
||||
private readonly string _pattern;
|
||||
private Regex? _regex;
|
||||
|
||||
@@ -7,9 +7,28 @@ namespace Umbraco.Cms.Core.Mail;
|
||||
/// </summary>
|
||||
public interface IEmailSender
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends a message asynchronously.
|
||||
/// </summary>
|
||||
[Obsolete("Please use the overload with expires parameter. Scheduled for removal in Umbraco 18.")]
|
||||
Task SendAsync(EmailMessage message, string emailType);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a message asynchronously.
|
||||
/// </summary>
|
||||
[Obsolete("Please use the overload with expires parameter. Scheduled for removal in Umbraco 18.")]
|
||||
Task SendAsync(EmailMessage message, string emailType, bool enableNotification);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a message asynchronously.
|
||||
/// </summary>
|
||||
Task SendAsync(EmailMessage message, string emailType, bool enableNotification = false, TimeSpan? expires = null)
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
=> SendAsync(message, emailType, enableNotification);
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
|
||||
/// <summary>
|
||||
/// Verifies if the email sender is configured to send emails.
|
||||
/// </summary>
|
||||
bool CanSendRequiredEmail();
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ internal sealed class NotImplementedEmailSender : IEmailSender
|
||||
throw new NotImplementedException(
|
||||
"To send an Email ensure IEmailSender is implemented with a custom implementation");
|
||||
|
||||
public Task SendAsync(EmailMessage message, string emailType, bool enableNotification, TimeSpan? expires) =>
|
||||
throw new NotImplementedException(
|
||||
"To send an Email ensure IEmailSender is implemented with a custom implementation");
|
||||
|
||||
public bool CanSendRequiredEmail()
|
||||
=> throw new NotImplementedException(
|
||||
"To send an Email ensure IEmailSender is implemented with a custom implementation");
|
||||
|
||||
@@ -12,7 +12,7 @@ public class X : OEmbedProviderBase
|
||||
{
|
||||
}
|
||||
|
||||
public override string ApiEndpoint => "http://publish.twitter.com/oembed";
|
||||
public override string ApiEndpoint => "https://publish.x.com/oembed";
|
||||
|
||||
public override string[] UrlSchemeRegex => new[] { @"(https?:\/\/(www\.)?)(twitter|x)\.com\/.*\/status\/.*" };
|
||||
|
||||
|
||||
@@ -23,4 +23,11 @@ public class RichTextBlockValue : BlockValue<RichTextBlockLayoutItem>
|
||||
/// <inheritdoc />
|
||||
[JsonIgnore]
|
||||
public override string PropertyEditorAlias => Constants.PropertyEditors.Aliases.RichText;
|
||||
|
||||
/// <inheritdoc />
|
||||
#pragma warning disable CS0672 // Member overrides obsolete member
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
public override bool SupportsBlockLayoutAlias(string alias) => base.SupportsBlockLayoutAlias(alias) || alias.Equals("Umbraco.TinyMCE");
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
#pragma warning restore CS0672 // Member overrides obsolete member
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ public interface IPropertyType : IEntity, IRememberBeingDirty
|
||||
/// </summary>
|
||||
int DataTypeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Guid unique identifier of the datatype for this property type.
|
||||
/// </summary>
|
||||
Guid DataTypeKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -47,6 +47,7 @@ public class PropertyType : EntityBase, IPropertyType, IEquatable<PropertyType>
|
||||
if (dataType.HasIdentity)
|
||||
{
|
||||
_dataTypeId = dataType.Id;
|
||||
_dataTypeKey = dataType.Key;
|
||||
}
|
||||
|
||||
_propertyEditorAlias = dataType.EditorAlias;
|
||||
@@ -159,6 +160,7 @@ public class PropertyType : EntityBase, IPropertyType, IEquatable<PropertyType>
|
||||
set => SetPropertyValueAndDetectChanges(value, ref _dataTypeId, nameof(DataTypeId));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
[DataMember]
|
||||
public Guid DataTypeKey
|
||||
{
|
||||
|
||||
@@ -53,6 +53,12 @@ public interface IPublishedContentTypeFactory
|
||||
/// </summary>
|
||||
PublishedDataType GetDataType(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Clears the internal data type cache.
|
||||
/// </summary>
|
||||
void ClearDataTypeCache()
|
||||
{ }
|
||||
|
||||
/// <summary>
|
||||
/// Notifies the factory of datatype changes.
|
||||
/// </summary>
|
||||
|
||||
@@ -65,6 +65,22 @@ public class PublishedContentTypeFactory : IPublishedContentTypeFactory
|
||||
return dataType;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ClearDataTypeCache()
|
||||
{
|
||||
if (_publishedDataTypes is null)
|
||||
{
|
||||
// Not initialized yet, so skip and avoid lock
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_publishedDataTypesLocker)
|
||||
{
|
||||
// Clear cache (and let it lazy initialize again later)
|
||||
_publishedDataTypes = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void NotifyDataTypeChanges(params int[] ids)
|
||||
{
|
||||
|
||||
@@ -24,6 +24,7 @@ public sealed class ContentPublishedNotification : EnumerableObjectNotification<
|
||||
|
||||
public ContentPublishedNotification(IEnumerable<IContent> target, EventMessages messages, bool includeDescendants)
|
||||
: base(target, messages) => IncludeDescendants = includeDescendants;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a enumeration of <see cref="IContent"/> which are being published.
|
||||
/// </summary>
|
||||
|
||||
@@ -38,5 +38,11 @@ public interface ILanguageRepository : IReadWriteQueryRepository<int, ILanguage>
|
||||
/// </remarks>
|
||||
int? GetDefaultId();
|
||||
|
||||
/// <summary>
|
||||
/// Gets multiple language ISO codes from the provided Ids.
|
||||
/// </summary>
|
||||
/// <param name="ids">The language Ids.</param>
|
||||
/// <param name="throwOnNotFound">Indicates whether to throw an exception if the provided Id is not found as a language.</param>
|
||||
/// <returns></returns>
|
||||
string[] GetIsoCodesByIds(ICollection<int> ids, bool throwOnNotFound = true);
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@ using System.Globalization;
|
||||
using System.Runtime.Serialization;
|
||||
using System.Xml.Linq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.IO;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Editors;
|
||||
using Umbraco.Cms.Core.Models.Validation;
|
||||
using Umbraco.Cms.Core.PropertyEditors.Validators;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Strings;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
@@ -20,6 +22,9 @@ namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
[DataContract]
|
||||
public class DataValueEditor : IDataValueEditor
|
||||
{
|
||||
private const string ContentCacheKeyFormat = nameof(DataValueEditor) + "_Content_{0}";
|
||||
private const string MediaCacheKeyFormat = nameof(DataValueEditor) + "_Media_{0}";
|
||||
|
||||
private readonly IJsonSerializer? _jsonSerializer;
|
||||
private readonly IShortStringHelper _shortStringHelper;
|
||||
|
||||
@@ -415,4 +420,155 @@ public class DataValueEditor : IDataValueEditor
|
||||
|
||||
return value.TryConvertTo(valueType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a <see cref="IContent"/> instance by its unique identifier, using the provided request cache to avoid redundant
|
||||
/// lookups within the same request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method caches content lookups for the duration of the current request to improve performance when the same content
|
||||
/// item may be accessed multiple times. This is particularly useful in scenarios involving multiple languages or blocks.
|
||||
/// </remarks>
|
||||
/// <param name="key">The unique identifier of the content item to retrieve.</param>
|
||||
/// <param name="requestCache">The request-scoped cache used to store and retrieve content items for the duration of the current request.</param>
|
||||
/// <param name="contentService">The content service used to fetch the content item if it is not found in the cache.</param>
|
||||
/// <returns>The <see cref="IContent"/> instance corresponding to the specified key, or null if no such content item exists.</returns>
|
||||
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
|
||||
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
|
||||
"Scheduled for removal in Umbraco 19.")]
|
||||
protected static IContent? GetAndCacheContentById(Guid key, IRequestCache requestCache, IContentService contentService)
|
||||
{
|
||||
if (requestCache.IsAvailable is false)
|
||||
{
|
||||
return contentService.GetById(key);
|
||||
}
|
||||
|
||||
var cacheKey = string.Format(ContentCacheKeyFormat, key);
|
||||
IContent? content = requestCache.GetCacheItem<IContent?>(cacheKey);
|
||||
if (content is null)
|
||||
{
|
||||
content = contentService.GetById(key);
|
||||
if (content is not null)
|
||||
{
|
||||
requestCache.Set(cacheKey, content);
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the specified <see cref="IContent"/> item to the request cache using its unique key.
|
||||
/// </summary>
|
||||
/// <param name="content">The content item to cache.</param>
|
||||
/// <param name="requestCache">The request cache in which to store the content item.</param>
|
||||
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
|
||||
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
|
||||
"Scheduled for removal in Umbraco 19.")]
|
||||
protected static void CacheContentById(IContent content, IRequestCache requestCache)
|
||||
{
|
||||
if (requestCache.IsAvailable is false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cacheKey = string.Format(ContentCacheKeyFormat, content.Key);
|
||||
requestCache.Set(cacheKey, content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a <see cref="IMedia"/> instance by its unique identifier, using the provided request cache to avoid redundant
|
||||
/// lookups within the same request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This method caches media lookups for the duration of the current request to improve performance when the same media
|
||||
/// item may be accessed multiple times. This is particularly useful in scenarios involving multiple languages or blocks.
|
||||
/// </remarks>
|
||||
/// <param name="key">The unique identifier of the media item to retrieve.</param>
|
||||
/// <param name="requestCache">The request-scoped cache used to store and retrieve media items for the duration of the current request.</param>
|
||||
/// <param name="mediaService">The media service used to fetch the media item if it is not found in the cache.</param>
|
||||
/// <returns>The <see cref="IMedia"/> instance corresponding to the specified key, or null if no such media item exists.</returns>
|
||||
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
|
||||
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
|
||||
"Scheduled for removal in Umbraco 19.")]
|
||||
protected static IMedia? GetAndCacheMediaById(Guid key, IRequestCache requestCache, IMediaService mediaService)
|
||||
{
|
||||
if (requestCache.IsAvailable is false)
|
||||
{
|
||||
return mediaService.GetById(key);
|
||||
}
|
||||
|
||||
var cacheKey = string.Format(MediaCacheKeyFormat, key);
|
||||
IMedia? media = requestCache.GetCacheItem<IMedia?>(cacheKey);
|
||||
|
||||
if (media is null)
|
||||
{
|
||||
media = mediaService.GetById(key);
|
||||
if (media is not null)
|
||||
{
|
||||
requestCache.Set(cacheKey, media);
|
||||
}
|
||||
}
|
||||
|
||||
return media;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the specified <see cref="IMedia"/> item to the request cache using its unique key.
|
||||
/// </summary>
|
||||
/// <param name="media">The media item to cache.</param>
|
||||
/// <param name="requestCache">The request cache in which to store the media item.</param>
|
||||
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
|
||||
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
|
||||
"Scheduled for removal in Umbraco 19.")]
|
||||
protected static void CacheMediaById(IMedia media, IRequestCache requestCache)
|
||||
{
|
||||
if (requestCache.IsAvailable is false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cacheKey = string.Format(MediaCacheKeyFormat, media.Key);
|
||||
requestCache.Set(cacheKey, media);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the content item identified by the specified key is present in the request cache.
|
||||
/// </summary>
|
||||
/// <param name="key">The unique identifier for the content item to check for in the cache.</param>
|
||||
/// <param name="requestCache">The request cache in which to look for the content item.</param>
|
||||
/// <returns>true if the content item is already cached in the request cache; otherwise, false.</returns>
|
||||
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
|
||||
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
|
||||
"Scheduled for removal in Umbraco 19.")]
|
||||
protected static bool IsContentAlreadyCached(Guid key, IRequestCache requestCache)
|
||||
{
|
||||
if (requestCache.IsAvailable is false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var cacheKey = string.Format(ContentCacheKeyFormat, key);
|
||||
return requestCache.GetCacheItem<IContent?>(cacheKey) is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the media item identified by the specified key is present in the request cache.
|
||||
/// </summary>
|
||||
/// <param name="key">The unique identifier for the media item to check for in the cache.</param>
|
||||
/// <param name="requestCache">The request cache in which to look for the media item.</param>
|
||||
/// <returns>true if the media item is already cached in the request cache; otherwise, false.</returns>
|
||||
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
|
||||
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
|
||||
"Scheduled for removal in Umbraco 19.")]
|
||||
protected static bool IsMediaAlreadyCached(Guid key, IRequestCache requestCache)
|
||||
{
|
||||
if (requestCache.IsAvailable is false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var cacheKey = string.Format(MediaCacheKeyFormat, key);
|
||||
return requestCache.GetCacheItem<IMedia?>(cacheKey) is not null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
|
||||
/// <summary>
|
||||
/// Optionally implemented by property editors, this defines a contract for caching entities that are referenced in block values.
|
||||
/// </summary>
|
||||
[Obsolete("This interface is available for support of request caching retrieved entities in property value editors that implement it. " +
|
||||
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
|
||||
"Scheduled for removal in Umbraco 19.")]
|
||||
public interface ICacheReferencedEntities
|
||||
{
|
||||
/// <summary>
|
||||
/// Caches the entities referenced by the provided block data values.
|
||||
/// </summary>
|
||||
/// <param name="values">An enumerable collection of block values that may contain the entities to be cached.</param>
|
||||
[Obsolete("This method is available for support of request caching retrieved entities in derived property value editors. " +
|
||||
"The intention is to supersede this with lazy loaded read locks, which will make this unnecessary. " +
|
||||
"Scheduled for removal in Umbraco 19.")]
|
||||
void CacheReferencedEntities(IEnumerable<object> values);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core.Extensions;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
@@ -42,7 +43,7 @@ public class PublishedUrlInfoProvider : IPublishedUrlInfoProvider
|
||||
public async Task<ISet<UrlInfo>> GetAllAsync(IContent content)
|
||||
{
|
||||
HashSet<UrlInfo> urlInfos = [];
|
||||
var cultures = (await _languageService.GetAllAsync()).Select(x => x.IsoCode).ToArray();
|
||||
IEnumerable<string> cultures = await _languageService.GetAllIsoCodesAsync();
|
||||
|
||||
// First we get the urls of all cultures, using the published router, meaning we respect any extensions.
|
||||
foreach (var culture in cultures)
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Extensions;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
@@ -118,7 +119,7 @@ public static class UrlProviderExtensions
|
||||
// and, not only for those assigned to domains in the branch, because we want
|
||||
// to show what GetUrl() would return, for every culture.
|
||||
var urls = new HashSet<UrlInfo>();
|
||||
var cultures = (await languageService.GetAllAsync()).Select(x => x.IsoCode).ToList();
|
||||
IEnumerable<string> cultures = await languageService.GetAllIsoCodesAsync();
|
||||
|
||||
// get all URLs for all cultures
|
||||
// in a HashSet, so de-duplicates too
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace Umbraco.Cms.Core.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Shared authorization logic for user group assignment.
|
||||
/// </summary>
|
||||
public static class UserGroupAssignmentAuthorization
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the group aliases that the performing user is not authorized to assign.
|
||||
/// </summary>
|
||||
/// <param name="performingUserGroupAliases">The group aliases the performing user belongs to.</param>
|
||||
/// <param name="requestedGroupAliases">The group aliases being assigned to the target user.</param>
|
||||
/// <param name="existingGroupAliases">The group aliases the target user currently belongs to.</param>
|
||||
/// <returns>
|
||||
/// Group aliases that are being added but the performing user does not belong to.
|
||||
/// An empty collection means the assignment is authorized.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// Non-admin users can remove any groups but can only add groups they themselves belong to.
|
||||
/// Callers should check for admin status before calling this method, as admins bypass this check.
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<string> GetUnauthorizedGroupAssignments(
|
||||
IEnumerable<string> performingUserGroupAliases,
|
||||
IEnumerable<string> requestedGroupAliases,
|
||||
IEnumerable<string> existingGroupAliases)
|
||||
{
|
||||
var performingGroups = performingUserGroupAliases.ToHashSet(StringComparer.InvariantCultureIgnoreCase);
|
||||
var existingGroups = existingGroupAliases.ToHashSet(StringComparer.InvariantCultureIgnoreCase);
|
||||
|
||||
return requestedGroupAliases
|
||||
.Where(alias => existingGroups.Contains(alias) is false && performingGroups.Contains(alias) is false)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Extensions;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.ContentEditing;
|
||||
using Umbraco.Cms.Core.Models.Membership;
|
||||
@@ -104,8 +105,8 @@ internal sealed class ContentEditingService
|
||||
{
|
||||
// If no cultures are provided, we are asking to validate all cultures. But if the user doesn't have access to all, we
|
||||
// should only validate the ones they do.
|
||||
var allCultures = (await _languageService.GetAllAsync()).Select(x => x.IsoCode).ToList();
|
||||
return allowedCultures.Count == allCultures.Count ? null : allowedCultures;
|
||||
IEnumerable<string> allCultures = await _languageService.GetAllIsoCodesAsync();
|
||||
return allowedCultures.Count == allCultures.Count() ? null : allowedCultures;
|
||||
}
|
||||
|
||||
// If explicit cultures are provided, we should only validate the ones the user has access to.
|
||||
|
||||
@@ -2,11 +2,13 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Extensions;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.ContentEditing;
|
||||
using Umbraco.Cms.Core.Models.ContentPublishing;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
using Umbraco.Cms.Core.Web;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Core.Services;
|
||||
@@ -25,6 +27,7 @@ internal sealed class ContentPublishingService : IContentPublishingService
|
||||
private readonly IRelationService _relationService;
|
||||
private readonly ILogger<ContentPublishingService> _logger;
|
||||
private readonly ILongRunningOperationService _longRunningOperationService;
|
||||
private readonly IUmbracoContextFactory _umbracoContextFactory;
|
||||
|
||||
public ContentPublishingService(
|
||||
ICoreScopeProvider coreScopeProvider,
|
||||
@@ -36,7 +39,8 @@ internal sealed class ContentPublishingService : IContentPublishingService
|
||||
IOptionsMonitor<ContentSettings> optionsMonitor,
|
||||
IRelationService relationService,
|
||||
ILogger<ContentPublishingService> logger,
|
||||
ILongRunningOperationService longRunningOperationService)
|
||||
ILongRunningOperationService longRunningOperationService,
|
||||
IUmbracoContextFactory umbracoContextFactory)
|
||||
{
|
||||
_coreScopeProvider = coreScopeProvider;
|
||||
_contentService = contentService;
|
||||
@@ -52,6 +56,7 @@ internal sealed class ContentPublishingService : IContentPublishingService
|
||||
{
|
||||
_contentSettings = contentSettings;
|
||||
});
|
||||
_umbracoContextFactory = umbracoContextFactory;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -165,7 +170,7 @@ internal sealed class ContentPublishingService : IContentPublishingService
|
||||
return Attempt.FailWithStatus(ContentPublishingOperationStatus.CannotPublishInvariantWhenVariant, new ContentPublishingResult());
|
||||
}
|
||||
|
||||
IEnumerable<string> validCultures = (await _languageService.GetAllAsync()).Select(x => x.IsoCode);
|
||||
IEnumerable<string> validCultures = await _languageService.GetAllIsoCodesAsync();
|
||||
if (validCultures.ContainsAll(cultures) is false)
|
||||
{
|
||||
scope.Complete();
|
||||
@@ -289,7 +294,7 @@ internal sealed class ContentPublishingService : IContentPublishingService
|
||||
return MapInternalPublishingAttempt(minimalAttempt);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Starting async background thread for publishing branch.");
|
||||
_logger.LogDebug("Starting long running operation for publishing branch {Key} on background thread.", key);
|
||||
Attempt<Guid, LongRunningOperationEnqueueStatus> enqueueAttempt = await _longRunningOperationService.RunAsync(
|
||||
PublishBranchOperationType,
|
||||
async _ => await PerformPublishBranchAsync(key, cultures, publishBranchFilter, userKey, returnContent: false),
|
||||
@@ -323,6 +328,10 @@ internal sealed class ContentPublishingService : IContentPublishingService
|
||||
Guid userKey,
|
||||
bool returnContent)
|
||||
{
|
||||
// Ensure we have an UmbracoContext in case running on a background thread so operations that run in the published notification handlers
|
||||
// have access to this (e.g. webhooks).
|
||||
using UmbracoContextReference umbracoContextReference = _umbracoContextFactory.EnsureUmbracoContext();
|
||||
|
||||
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
|
||||
IContent? content = _contentService.GetById(key);
|
||||
if (content is null)
|
||||
@@ -488,7 +497,7 @@ internal sealed class ContentPublishingService : IContentPublishingService
|
||||
return Attempt.Fail(ContentPublishingOperationStatus.CannotPublishVariantWhenNotVariant);
|
||||
}
|
||||
|
||||
var validCultures = (await _languageService.GetAllAsync()).Select(x => x.IsoCode).ToArray();
|
||||
var validCultures = (await _languageService.GetAllIsoCodesAsync()).ToArray();
|
||||
|
||||
foreach (var culture in cultures)
|
||||
{
|
||||
|
||||
@@ -2207,7 +2207,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();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.ContentTypeEditing;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
using Umbraco.Cms.Core.Strings;
|
||||
@@ -407,7 +407,7 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
|
||||
}
|
||||
|
||||
// This this method gets aliases across documents, members, and media, so it covers it all
|
||||
private bool ContentTypeAliasIsInUse(string alias) => _contentTypeService.GetAllContentTypeAliases().Contains(alias);
|
||||
private bool ContentTypeAliasIsInUse(string alias) => _contentTypeService.GetAllContentTypeAliases().InvariantContains(alias);
|
||||
|
||||
private bool ContentTypeAliasCanBeUsedFor(string alias, Guid key)
|
||||
{
|
||||
|
||||
@@ -175,7 +175,7 @@ internal sealed class MediaTypeEditingService : ContentTypeEditingServiceBase<IM
|
||||
continue;
|
||||
}
|
||||
|
||||
allowedFileExtensionsByMediaType[mediaType] = fileUploadConfiguration.FileExtensions;
|
||||
allowedFileExtensionsByMediaType[mediaType] = fileUploadConfiguration.FileExtensions ?? []; // Although we never expect null here, legacy data type configuration did allow it.
|
||||
}
|
||||
|
||||
return allowedFileExtensionsByMediaType;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Umbraco.Cms.Core.Extensions;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.ContentEditing;
|
||||
using Umbraco.Cms.Core.Models.ContentEditing.Validation;
|
||||
@@ -137,7 +138,7 @@ internal abstract class ContentValidationServiceBase<TContentType>
|
||||
return invalidCultures.IsCollectionEmpty();
|
||||
}
|
||||
|
||||
private async Task<string[]> GetCultureCodes() => (await _languageService.GetAllAsync()).Select(language => language.IsoCode).ToArray();
|
||||
private async Task<string[]> GetCultureCodes() => (await _languageService.GetAllIsoCodesAsync()).ToArray();
|
||||
|
||||
private IEnumerable<PropertyValidationError> ValidateProperty(IPropertyType propertyType, PropertyValueModel? propertyValueModel, PropertyValidationContext validationContext)
|
||||
{
|
||||
|
||||
@@ -432,7 +432,8 @@ public class DocumentUrlService : IDocumentUrlService
|
||||
|
||||
if (draftUrlSegments.Any() is false)
|
||||
{
|
||||
_logger.LogWarning("No draft URL segments found for document {DocumentKey} in culture {Culture}", document.Key, culture ?? "{null}");
|
||||
// Log at debug level because this is expected when a document is not published in a given language.
|
||||
_logger.LogDebug("No draft URL segments found for document {DocumentKey} in culture {Culture}", document.Key, culture ?? "{null}");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -856,7 +857,7 @@ public class DocumentUrlService : IDocumentUrlService
|
||||
.Concat(_contentService.GetAncestors(documentIdAttempt.Result).Select(x => x.Key).Reverse());
|
||||
|
||||
IEnumerable<ILanguage> languages = await _languageService.GetAllAsync();
|
||||
var cultures = languages.ToDictionary(x=>x.IsoCode);
|
||||
var cultures = languages.ToDictionary(x => x.IsoCode);
|
||||
|
||||
Guid[] ancestorsOrSelfKeysArray = ancestorsOrSelfKeys as Guid[] ?? ancestorsOrSelfKeys.ToArray();
|
||||
Dictionary<Guid, Task<ILookup<string, Domain>>> ancestorOrSelfKeyToDomains = ancestorsOrSelfKeysArray
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
|
||||
namespace Umbraco.Cms.Core.Services;
|
||||
@@ -32,9 +32,9 @@ public interface ILanguageService
|
||||
Task<string> GetDefaultIsoCodeAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Gets all available languages
|
||||
/// Gets all available languages.
|
||||
/// </summary>
|
||||
/// <returns>An enumerable list of <see cref="ILanguage" /> objects</returns>
|
||||
/// <returns>An enumerable list of <see cref="ILanguage" /> objects.</returns>
|
||||
Task<IEnumerable<ILanguage>> GetAllAsync();
|
||||
|
||||
/// <summary>
|
||||
@@ -44,29 +44,29 @@ public interface ILanguageService
|
||||
Task<IEnumerable<ILanguage>> GetMultipleAsync(IEnumerable<string> isoCodes);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing <see cref="ILanguage" /> object
|
||||
/// Updates an existing <see cref="ILanguage" /> object.
|
||||
/// </summary>
|
||||
/// <param name="language"><see cref="ILanguage" /> to update</param>
|
||||
/// <param name="userKey">Key of the user saving the language</param>
|
||||
Task<Attempt<ILanguage, LanguageOperationStatus>> UpdateAsync(ILanguage language, Guid userKey);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="ILanguage" /> object
|
||||
/// Creates a new <see cref="ILanguage" /> object.
|
||||
/// </summary>
|
||||
/// <param name="language"><see cref="ILanguage" /> to create</param>
|
||||
/// <param name="userKey">Key of the user creating the language</param>
|
||||
/// <param name="language"><see cref="ILanguage" /> to create.</param>
|
||||
/// <param name="userKey">Key of the user creating the language.</param>
|
||||
Task<Attempt<ILanguage, LanguageOperationStatus>> CreateAsync(ILanguage language, Guid userKey);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a <see cref="ILanguage" /> by removing it and its usages from the db
|
||||
/// Deletes a <see cref="ILanguage" /> by removing it and its usages from the database.
|
||||
/// </summary>
|
||||
/// <param name="isoCode">The ISO code of the <see cref="ILanguage" /> to delete</param>
|
||||
/// <param name="userKey">Key of the user deleting the language</param>
|
||||
/// <param name="isoCode">The ISO code of the <see cref="ILanguage" /> to delete.</param>
|
||||
/// <param name="userKey">Key of the user deleting the language.</param>
|
||||
Task<Attempt<ILanguage?, LanguageOperationStatus>> DeleteAsync(string isoCode, Guid userKey);
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the isoCodes of configured languages by their Ids
|
||||
/// Retrieves the ISO codes of configured languages by their Ids.
|
||||
/// </summary>
|
||||
/// <param name="ids">The ids of the configured <see cref="ILanguage" />s</param>
|
||||
/// <returns>The ISO codes of the <see cref="ILanguage" />s</returns>
|
||||
|
||||
@@ -2,7 +2,22 @@ using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
|
||||
namespace Umbraco.Cms.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a service for asynchronously retrieving embeddable HTML markup for a specified resource using the oEmbed
|
||||
/// protocol.
|
||||
/// </summary>
|
||||
public interface IOEmbedService
|
||||
{
|
||||
/// <summary>
|
||||
/// Asynchronously retrieves the embeddable HTML markup for the specified resource.
|
||||
/// </summary>
|
||||
/// <remarks>The returned markup is suitable for embedding in web pages. The width and height parameters
|
||||
/// may be ignored by some providers depending on their capabilities.</remarks>
|
||||
/// <param name="url">The URI of the resource to retrieve markup for. Must be a valid, absolute URI.</param>
|
||||
/// <param name="width">The optional maximum width, in pixels, for the embedded content. If null, the default width is used.</param>
|
||||
/// <param name="height">The optional maximum height, in pixels, for the embedded content. If null, the default height is used.</param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests. The operation is canceled if the token is triggered.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The result contains an Attempt with the HTML markup if
|
||||
/// successful, or an oEmbed operation status indicating the reason for failure.</returns>
|
||||
Task<Attempt<string, OEmbedOperationStatus>> GetMarkupAsync(Uri url, int? width, int? height, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Membership;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
|
||||
@@ -107,8 +107,25 @@ public interface IUserGroupService
|
||||
/// <param name="userGroupKeys">The user groups the users should be part of.</param>
|
||||
/// <param name="userKeys">The user whose groups we want to alter.</param>
|
||||
/// <returns>An attempt indicating if the operation was a success as well as a more detailed <see cref="UserGroupOperationStatus"/>.</returns>
|
||||
[Obsolete("Please use the overload accepting all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
Task<Attempt<UserGroupOperationStatus>> UpdateUserGroupsOnUsersAsync(ISet<Guid> userGroupKeys, ISet<Guid> userKeys);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the users to have the groups specified, with authorization checks based on the performing user.
|
||||
/// </summary>
|
||||
/// <param name="userGroupKeys">The user groups the users should be part of.</param>
|
||||
/// <param name="userKeys">The user whose groups we want to alter.</param>
|
||||
/// <param name="performingUserKey">The key of the user performing the operation.</param>
|
||||
/// <returns>An attempt indicating if the operation was a success as well as a more detailed <see cref="UserGroupOperationStatus"/>.</returns>
|
||||
/// <remarks>
|
||||
/// Non-admin users can only add groups they themselves belong to. Removing groups is always allowed.
|
||||
/// </remarks>
|
||||
// TODO (V18): Remove default implementation.
|
||||
Task<Attempt<UserGroupOperationStatus>> UpdateUserGroupsOnUsersAsync(ISet<Guid> userGroupKeys, ISet<Guid> userKeys, Guid performingUserKey)
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
=> UpdateUserGroupsOnUsersAsync(userGroupKeys, userKeys);
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
|
||||
Task<Attempt<UserGroupOperationStatus>> AddUsersToUserGroupAsync(UsersToUserGroupManipulationModel addUsersModel, Guid performingUserKey);
|
||||
Task<Attempt<UserGroupOperationStatus>> RemoveUsersFromUserGroupAsync(UsersToUserGroupManipulationModel removeUsersModel, Guid performingUserKey);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
|
||||
@@ -557,7 +557,7 @@ public class NotificationService : INotificationService
|
||||
{
|
||||
ThreadPool.QueueUserWorkItem(state =>
|
||||
{
|
||||
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug))
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug("Begin processing notifications.");
|
||||
}
|
||||
@@ -569,9 +569,9 @@ public class NotificationService : INotificationService
|
||||
{
|
||||
try
|
||||
{
|
||||
_emailSender.SendAsync(request.Mail, Constants.Web.EmailTypes.Notification).GetAwaiter()
|
||||
_emailSender.SendAsync(request.Mail, Constants.Web.EmailTypes.Notification, false, null).GetAwaiter()
|
||||
.GetResult();
|
||||
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug))
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug("Notification '{Action}' sent to {Username} ({Email})", request.Action, request.UserName, request.Email);
|
||||
}
|
||||
|
||||
@@ -6,22 +6,30 @@ using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
|
||||
namespace Umbraco.Cms.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implements <see cref="IOEmbedService"/> for retrieving embeddable HTML markup using the oEmbed protocol.
|
||||
/// </summary>
|
||||
public class OEmbedService : IOEmbedService
|
||||
{
|
||||
private readonly EmbedProvidersCollection _embedProvidersCollection;
|
||||
private readonly ILogger<OEmbedService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="OEmbedService"/> class.
|
||||
/// </summary>
|
||||
public OEmbedService(EmbedProvidersCollection embedProvidersCollection, ILogger<OEmbedService> logger)
|
||||
{
|
||||
_embedProvidersCollection = embedProvidersCollection;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Attempt<string, OEmbedOperationStatus>> GetMarkupAsync(Uri url, int? maxWidth, int? maxHeight, CancellationToken cancellationToken)
|
||||
{
|
||||
// Find the first provider that supports the URL
|
||||
IEmbedProvider? matchedProvider = _embedProvidersCollection
|
||||
.FirstOrDefault(provider => provider.UrlSchemeRegex.Any(regex=>new Regex(regex, RegexOptions.IgnoreCase).IsMatch(url.OriginalString)));
|
||||
.FirstOrDefault(provider => provider.UrlSchemeRegex
|
||||
.Any(regex => new Regex(regex, RegexOptions.IgnoreCase).IsMatch(url.OriginalString)));
|
||||
|
||||
if (matchedProvider is null)
|
||||
{
|
||||
@@ -39,8 +47,8 @@ public class OEmbedService : IOEmbedService
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Unexpected exception happened while trying to get oembed markup. Provider: {Provider}",matchedProvider.GetType().Name);
|
||||
Attempt.FailWithStatus(OEmbedOperationStatus.UnexpectedException, string.Empty, e);
|
||||
_logger.LogError(e, "Unexpected exception happened while trying to get oEmbed markup. Provider: {Provider}", matchedProvider.GetType().Name);
|
||||
return Attempt.FailWithStatus(OEmbedOperationStatus.UnexpectedException, string.Empty, e);
|
||||
}
|
||||
|
||||
return Attempt.FailWithStatus(OEmbedOperationStatus.ProviderReturnedInvalidResult, string.Empty);
|
||||
|
||||
@@ -8,6 +8,7 @@ using Umbraco.Cms.Core.Persistence;
|
||||
using Umbraco.Cms.Core.Persistence.Querying;
|
||||
using Umbraco.Cms.Core.Persistence.Repositories;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Services.AuthorizationStatus;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
using Umbraco.Extensions;
|
||||
@@ -210,9 +211,24 @@ internal sealed class UserGroupService : RepositoryService, IUserGroupService
|
||||
return Attempt.Succeed(UserGroupOperationStatus.Success);
|
||||
}
|
||||
|
||||
public async Task<Attempt<UserGroupOperationStatus>> UpdateUserGroupsOnUsersAsync(
|
||||
// TODO (V19): Collapse the following three methods into a single one, once the obsolete overload
|
||||
// of UpdateUserGroupsOnUsersAsync is removed from the interface.
|
||||
|
||||
public Task<Attempt<UserGroupOperationStatus>> UpdateUserGroupsOnUsersAsync(
|
||||
ISet<Guid> userGroupKeys,
|
||||
ISet<Guid> userKeys)
|
||||
=> UpdateUserGroupsOnUsersInternalAsync(userGroupKeys, userKeys, performingUserKey: null);
|
||||
|
||||
public Task<Attempt<UserGroupOperationStatus>> UpdateUserGroupsOnUsersAsync(
|
||||
ISet<Guid> userGroupKeys,
|
||||
ISet<Guid> userKeys,
|
||||
Guid performingUserKey)
|
||||
=> UpdateUserGroupsOnUsersInternalAsync(userGroupKeys, userKeys, performingUserKey);
|
||||
|
||||
private async Task<Attempt<UserGroupOperationStatus>> UpdateUserGroupsOnUsersInternalAsync(
|
||||
ISet<Guid> userGroupKeys,
|
||||
ISet<Guid> userKeys,
|
||||
Guid? performingUserKey)
|
||||
{
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope();
|
||||
|
||||
@@ -222,6 +238,40 @@ internal sealed class UserGroupService : RepositoryService, IUserGroupService
|
||||
.Select(x => x.ToReadOnlyGroup())
|
||||
.ToArray();
|
||||
|
||||
// Authorize the performing user if provided.
|
||||
if (performingUserKey.HasValue)
|
||||
{
|
||||
IUser? performingUser = await _userService.GetAsync(performingUserKey.Value);
|
||||
if (performingUser is null)
|
||||
{
|
||||
scope.Complete();
|
||||
return Attempt.Fail(UserGroupOperationStatus.MissingUser);
|
||||
}
|
||||
|
||||
if (performingUser.IsAdmin() is false)
|
||||
{
|
||||
string[] performingUserGroupAliases = performingUser.Groups.Select(g => g.Alias).ToArray();
|
||||
string[] requestedGroupAliases = userGroups.Select(g => g.Alias).ToArray();
|
||||
|
||||
foreach (IUser user in users)
|
||||
{
|
||||
IEnumerable<string> existingGroupAliases = user.Groups.Select(g => g.Alias);
|
||||
|
||||
IReadOnlyList<string> unauthorized = UserGroupAssignmentAuthorization
|
||||
.GetUnauthorizedGroupAssignments(performingUserGroupAliases, requestedGroupAliases, existingGroupAliases);
|
||||
|
||||
if (unauthorized.Count > 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"The performing user is not allowed to assign user group(s) '{GroupAliases}' because they do not belong to them.",
|
||||
string.Join(", ", unauthorized));
|
||||
scope.Complete();
|
||||
return Attempt.Fail(UserGroupOperationStatus.Unauthorized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This means that we're potentially de-admining a user, which might cause the admin group to be empty.
|
||||
if (userGroupKeys.Contains(Constants.Security.AdminGroupKey) is false)
|
||||
{
|
||||
|
||||
@@ -15,15 +15,15 @@ public sealed class HtmlLocalLinkParser
|
||||
// <a type="document" href="/{localLink:eed5fc6b-96fd-45a5-a0f1-b1adfb483c2f}" title="other page">other page</a>
|
||||
internal static readonly Regex LocalLinkTagPattern = new(
|
||||
@"<a.+?href=['""](?<locallink>\/?{localLink:(?<guid>[a-fA-F0-9-]+)})[^>]*?>",
|
||||
RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace | RegexOptions.Singleline);
|
||||
RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace | RegexOptions.Singleline | RegexOptions.Compiled);
|
||||
|
||||
internal static readonly Regex TypePattern = new(
|
||||
"""type=['"](?<type>(?:media|document))['"]""",
|
||||
RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace);
|
||||
RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled);
|
||||
|
||||
internal static readonly Regex LocalLinkPattern = new(
|
||||
@"href=['""](?<locallink>\/?(?:\{|\%7B)localLink:(?<guid>[a-zA-Z0-9-://]+)(?:\}|\%7D))",
|
||||
RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace);
|
||||
RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled);
|
||||
|
||||
private readonly IPublishedUrlProvider _publishedUrlProvider;
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
[CS0618]/[CS0672]/[SYSLIB0051]/[SYSLIB0044]/[SYSLIB0023]/[SYSLIB0003]/[SYSLIB0045] adjust obsolete references
|
||||
[CS0067] unused event
|
||||
[SA1405] debug provide message text
|
||||
[CS0168]/[CS0169] unused fields/variables
|
||||
[CS0168] and [CS0169] unused fields/variables
|
||||
[CS0183] always of type
|
||||
[SA1111] adjust parenthesis
|
||||
[CA2017] match parameters number
|
||||
|
||||
@@ -11,6 +11,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Extensions;
|
||||
using Umbraco.Cms.Core.Mapping;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.ContentEditing;
|
||||
@@ -260,9 +261,7 @@ public class BackOfficeExamineSearcher : IBackOfficeExamineSearcher
|
||||
// then nodeName will be matched normally with wildcards
|
||||
// the rest will be normal without wildcards
|
||||
|
||||
var allLangs = _languageService.GetAllAsync().GetAwaiter().GetResult()
|
||||
.Select(x => x.IsoCode.ToLowerInvariant())
|
||||
.ToList();
|
||||
var allLangs = _languageService.GetAllIsoCodesAsync().GetAwaiter().GetResult().Select(x => x.ToLowerInvariant()).ToList();
|
||||
|
||||
// the chars [*-_] in the query will mess everything up so let's remove those
|
||||
// However we cannot just remove - and _ since these signify a space, so we instead replace them with that.
|
||||
|
||||
@@ -10,7 +10,7 @@ using Umbraco.Cms.Core.Configuration.Models;
|
||||
namespace Umbraco.Cms.Infrastructure.Examine.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the index options to construct the Examine indexes
|
||||
/// Configures the index options to construct the Examine indexes.
|
||||
/// </summary>
|
||||
public sealed class ConfigureIndexOptions : IConfigureNamedOptions<LuceneDirectoryIndexOptions>
|
||||
{
|
||||
@@ -18,6 +18,9 @@ public sealed class ConfigureIndexOptions : IConfigureNamedOptions<LuceneDirecto
|
||||
private readonly IUmbracoIndexConfig _umbracoIndexConfig;
|
||||
private readonly IDeliveryApiContentIndexFieldDefinitionBuilder _deliveryApiContentIndexFieldDefinitionBuilder;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ConfigureIndexOptions"/> class.
|
||||
/// </summary>
|
||||
public ConfigureIndexOptions(
|
||||
IUmbracoIndexConfig umbracoIndexConfig,
|
||||
IOptions<IndexCreatorSettings> settings,
|
||||
@@ -28,24 +31,27 @@ public sealed class ConfigureIndexOptions : IConfigureNamedOptions<LuceneDirecto
|
||||
_deliveryApiContentIndexFieldDefinitionBuilder = deliveryApiContentIndexFieldDefinitionBuilder;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Configure(string? name, LuceneDirectoryIndexOptions options)
|
||||
{
|
||||
// When creating FieldDefinitions with Umbraco defaults, pass in any already defined to avoid overwriting
|
||||
// those added via a package or custom code.
|
||||
switch (name)
|
||||
{
|
||||
case Constants.UmbracoIndexes.InternalIndexName:
|
||||
options.Analyzer = new CultureInvariantWhitespaceAnalyzer();
|
||||
options.Validator = _umbracoIndexConfig.GetContentValueSetValidator();
|
||||
options.FieldDefinitions = new UmbracoFieldDefinitionCollection();
|
||||
options.FieldDefinitions = new UmbracoFieldDefinitionCollection(options.FieldDefinitions);
|
||||
break;
|
||||
case Constants.UmbracoIndexes.ExternalIndexName:
|
||||
options.Analyzer = new StandardAnalyzer(LuceneInfo.CurrentVersion);
|
||||
options.Validator = _umbracoIndexConfig.GetPublishedContentValueSetValidator();
|
||||
options.FieldDefinitions = new UmbracoFieldDefinitionCollection();
|
||||
options.FieldDefinitions = new UmbracoFieldDefinitionCollection(options.FieldDefinitions);
|
||||
break;
|
||||
case Constants.UmbracoIndexes.MembersIndexName:
|
||||
options.Analyzer = new CultureInvariantWhitespaceAnalyzer();
|
||||
options.Validator = _umbracoIndexConfig.GetMemberValueSetValidator();
|
||||
options.FieldDefinitions = new UmbracoFieldDefinitionCollection();
|
||||
options.FieldDefinitions = new UmbracoFieldDefinitionCollection(options.FieldDefinitions);
|
||||
break;
|
||||
case Constants.UmbracoIndexes.DeliveryApiContentIndexName:
|
||||
options.Analyzer = new StandardAnalyzer(LuceneInfo.CurrentVersion);
|
||||
@@ -64,6 +70,7 @@ public sealed class ConfigureIndexOptions : IConfigureNamedOptions<LuceneDirecto
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Configure(LuceneDirectoryIndexOptions options)
|
||||
=> throw new NotImplementedException("This is never called and is just part of the interface");
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<Description>Adds Examine searching support using Lucene to Umbraco CMS.</Description>
|
||||
<RootNamespace>Umbraco.Cms.Infrastructure.Examine</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
<PropertyGroup>
|
||||
<!--
|
||||
TODO: Fix and remove overrides:
|
||||
@@ -22,7 +22,6 @@
|
||||
<!-- Take top-level depedendency on System.Security.Cryptography.Xml, because Examine depends on a vulnerable version -->
|
||||
<PackageReference Include="System.Security.Cryptography.Xml" />
|
||||
<!-- Take top-level depedendency on Lucene.Net.Replicator, because Examine depends on a vulnerable version -->
|
||||
<!-- Take top-level depedendency on Lucene.Net.Replicator-->
|
||||
<PackageReference Include="Lucene.Net.Replicator" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
+2
-1
@@ -37,7 +37,8 @@ public class RecurringBackgroundJobHostedServiceRunner : IHostedService
|
||||
_logger.LogDebug("Creating background hosted service for {job}", jobName);
|
||||
IHostedService hostedService = _jobFactory(job);
|
||||
|
||||
_logger.LogInformation("Starting background hosted service for {job}", jobName);
|
||||
_logger.LogInformation("Starting a background hosted service for {job} with a delay of {delay}, running every {period}", jobName, job.Delay, job.Period);
|
||||
|
||||
await hostedService.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_hostedServices.Add(new NamedServiceJob(jobName, hostedService));
|
||||
|
||||
@@ -20,6 +20,7 @@ public static partial class UmbracoBuilderExtensions
|
||||
|
||||
// Add post migration notification handlers
|
||||
builder.AddNotificationHandler<UmbracoPlanExecutedNotification, ClearCsrfCookieHandler>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
|
||||
+26
-4
@@ -59,7 +59,19 @@ internal sealed class DeliveryApiContentIndexHandleContentChanges : DeliveryApiC
|
||||
RemoveFromIndex(pendingRemovals, index);
|
||||
pendingRemovals.Clear();
|
||||
|
||||
Reindex(content, index);
|
||||
ReIndexResult reIndexResult = Reindex(content, index);
|
||||
|
||||
|
||||
// When we get to this point, we are dealing with either
|
||||
// a refresh node or a refresh branch (see reindex =...).
|
||||
// A refresh branch can be many things, the Reindex function takes care of most scenarios.
|
||||
// But it only reindexes descendants if the base node has any changed cultures (see comments in that function)
|
||||
// So by checking what kind of operation it did when the initial indexrequest is for a refresh branch,
|
||||
// we can support reindexing a branch while the base node was unchanged.
|
||||
if (reIndexResult == ReIndexResult.Updated && changeTypes.HasType(TreeChangeTypes.RefreshBranch))
|
||||
{
|
||||
ReindexDescendants(content, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +80,7 @@ internal sealed class DeliveryApiContentIndexHandleContentChanges : DeliveryApiC
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
private void Reindex(IContent content, IIndex index)
|
||||
private ReIndexResult Reindex(IContent content, IIndex index)
|
||||
{
|
||||
// get the currently indexed cultures for the content
|
||||
CulturePublishStatus[] existingCultures = index
|
||||
@@ -95,16 +107,19 @@ internal sealed class DeliveryApiContentIndexHandleContentChanges : DeliveryApiC
|
||||
// we likely got here because a removal triggered a "refresh branch" notification, now we
|
||||
// need to delete every last culture of this content and all descendants
|
||||
RemoveFromIndex(content.Id, index);
|
||||
return;
|
||||
return ReIndexResult.Removed;
|
||||
}
|
||||
|
||||
// if the published state changed of any culture, chances are there are similar changes ot the content descendants
|
||||
// if the published state changed of any culture, chances are there are similar changes at the content descendants
|
||||
// that need to be reflected in the index, so we'll reindex all descendants
|
||||
var changedCulturePublishStatus = indexedCultures.Intersect(existingCultures).Count() != existingCultures.Length;
|
||||
if (changedCulturePublishStatus)
|
||||
{
|
||||
ReindexDescendants(content, index);
|
||||
return ReIndexResult.UpdatedWithDescendants;
|
||||
}
|
||||
|
||||
return ReIndexResult.Updated;
|
||||
}
|
||||
|
||||
private CulturePublishStatus[] UpdateIndex(IContent content, IIndex index)
|
||||
@@ -179,4 +194,11 @@ internal sealed class DeliveryApiContentIndexHandleContentChanges : DeliveryApiC
|
||||
|
||||
public override int GetHashCode() => HashCode.Combine(Culture, Published);
|
||||
}
|
||||
|
||||
private enum ReIndexResult
|
||||
{
|
||||
Updated,
|
||||
UpdatedWithDescendants,
|
||||
Removed,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,29 @@ public class UmbracoFieldDefinitionCollection : FieldDefinitionCollection
|
||||
new(UmbracoExamineFieldNames.VariesByCultureFieldName, FieldDefinitionTypes.Raw),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UmbracoFieldDefinitionCollection"/> class containing
|
||||
/// the default Umbraco field definitions.
|
||||
/// </summary>
|
||||
public UmbracoFieldDefinitionCollection()
|
||||
: base(UmbracoIndexFieldDefinitions)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UmbracoFieldDefinitionCollection"/> class containing the containing
|
||||
/// the default Umbraco field definitions, augmented or overridden by the provided definitions.
|
||||
/// </summary>
|
||||
/// <param name="definitions">Existing collection of field definitions.</param>
|
||||
public UmbracoFieldDefinitionCollection(FieldDefinitionCollection definitions)
|
||||
: base(UmbracoIndexFieldDefinitions)
|
||||
{
|
||||
foreach (FieldDefinition definition in definitions)
|
||||
{
|
||||
AddOrUpdate(definition);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overridden to dynamically add field definitions for culture variations
|
||||
/// </summary>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Configuration;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
@@ -28,7 +30,10 @@ public class UnattendedUpgrader : INotificationAsyncHandler<RuntimeUnattendedUpg
|
||||
private readonly IRuntimeState _runtimeState;
|
||||
private readonly IUmbracoVersion _umbracoVersion;
|
||||
private readonly UnattendedSettings _unattendedSettings;
|
||||
private readonly DistributedCache _distributedCache;
|
||||
private readonly ILogger<UnattendedUpgrader> _logger;
|
||||
|
||||
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
public UnattendedUpgrader(
|
||||
IProfilingLogger profilingLogger,
|
||||
IUmbracoVersion umbracoVersion,
|
||||
@@ -36,13 +41,36 @@ public class UnattendedUpgrader : INotificationAsyncHandler<RuntimeUnattendedUpg
|
||||
IRuntimeState runtimeState,
|
||||
PackageMigrationRunner packageMigrationRunner,
|
||||
IOptions<UnattendedSettings> unattendedSettings)
|
||||
: this(
|
||||
profilingLogger,
|
||||
umbracoVersion,
|
||||
databaseBuilder,
|
||||
runtimeState,
|
||||
packageMigrationRunner,
|
||||
unattendedSettings,
|
||||
StaticServiceProvider.Instance.GetRequiredService<DistributedCache>(),
|
||||
StaticServiceProvider.Instance.GetRequiredService<ILogger<UnattendedUpgrader>>())
|
||||
{
|
||||
_profilingLogger = profilingLogger ?? throw new ArgumentNullException(nameof(profilingLogger));
|
||||
_umbracoVersion = umbracoVersion ?? throw new ArgumentNullException(nameof(umbracoVersion));
|
||||
_databaseBuilder = databaseBuilder ?? throw new ArgumentNullException(nameof(databaseBuilder));
|
||||
_runtimeState = runtimeState ?? throw new ArgumentNullException(nameof(runtimeState));
|
||||
}
|
||||
|
||||
public UnattendedUpgrader(
|
||||
IProfilingLogger profilingLogger,
|
||||
IUmbracoVersion umbracoVersion,
|
||||
DatabaseBuilder databaseBuilder,
|
||||
IRuntimeState runtimeState,
|
||||
PackageMigrationRunner packageMigrationRunner,
|
||||
IOptions<UnattendedSettings> unattendedSettings,
|
||||
DistributedCache distributedCache,
|
||||
ILogger<UnattendedUpgrader> logger)
|
||||
{
|
||||
_profilingLogger = profilingLogger;
|
||||
_umbracoVersion = umbracoVersion;
|
||||
_databaseBuilder = databaseBuilder;
|
||||
_runtimeState = runtimeState;
|
||||
_packageMigrationRunner = packageMigrationRunner;
|
||||
_unattendedSettings = unattendedSettings.Value;
|
||||
_distributedCache = distributedCache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task HandleAsync(RuntimeUnattendedUpgradeNotification notification, CancellationToken cancellationToken)
|
||||
@@ -109,8 +137,13 @@ public class UnattendedUpgrader : INotificationAsyncHandler<RuntimeUnattendedUpg
|
||||
try
|
||||
{
|
||||
await _packageMigrationRunner.RunPackagePlansAsync(pendingMigrations);
|
||||
notification.UnattendedUpgradeResult = RuntimeUnattendedUpgradeNotification.UpgradeResult
|
||||
.PackageMigrationComplete;
|
||||
notification.UnattendedUpgradeResult = RuntimeUnattendedUpgradeNotification.UpgradeResult.PackageMigrationComplete;
|
||||
|
||||
// Migration plans may have changed published content, so refresh the distributed cache to ensure consistency on first request.
|
||||
_distributedCache.RefreshAllPublishedSnapshot();
|
||||
_logger.LogInformation(
|
||||
"Migration plans run: {Plans}. Triggered refresh of distributed published content cache.",
|
||||
string.Join(", ", pendingMigrations));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -15,28 +15,44 @@ namespace Umbraco.Cms.Infrastructure.Mail
|
||||
public class BasicSmtpEmailSenderClient : IEmailSenderClient
|
||||
{
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
public BasicSmtpEmailSenderClient(IOptionsMonitor<GlobalSettings> globalSettings)
|
||||
{
|
||||
_globalSettings = globalSettings.CurrentValue;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public BasicSmtpEmailSenderClient(IOptionsMonitor<GlobalSettings> globalSettings)
|
||||
=> _globalSettings = globalSettings.CurrentValue;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SendAsync(EmailMessage message)
|
||||
=> await SendAsync(message, null);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SendAsync(EmailMessage message, TimeSpan? expires)
|
||||
{
|
||||
using var client = new SmtpClient();
|
||||
|
||||
await client.ConnectAsync(
|
||||
_globalSettings.Smtp!.Host,
|
||||
_globalSettings.Smtp.Port,
|
||||
_globalSettings.Smtp.Port,
|
||||
(SecureSocketOptions)(int)_globalSettings.Smtp.SecureSocketOptions);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_globalSettings.Smtp.Username) &&
|
||||
!string.IsNullOrWhiteSpace(_globalSettings.Smtp.Password))
|
||||
!string.IsNullOrWhiteSpace(_globalSettings.Smtp.Password))
|
||||
{
|
||||
await client.AuthenticateAsync(_globalSettings.Smtp.Username, _globalSettings.Smtp.Password);
|
||||
}
|
||||
|
||||
var mimeMessage = message.ToMimeMessage(_globalSettings.Smtp!.From);
|
||||
|
||||
if (_globalSettings.IsSmtpExpiryConfigured)
|
||||
{
|
||||
expires ??= _globalSettings.Smtp.EmailExpiration;
|
||||
}
|
||||
|
||||
if (expires.HasValue)
|
||||
{
|
||||
// `Expires` header needs to be in RFC 1123/2822 compatible format
|
||||
mimeMessage.Headers.Add("Expires", DateTimeOffset.UtcNow.Add(expires.GetValueOrDefault()).ToString("R"));
|
||||
}
|
||||
|
||||
if (_globalSettings.Smtp.DeliveryMethod == SmtpDeliveryMethod.Network)
|
||||
{
|
||||
await client.SendAsync(mimeMessage);
|
||||
|
||||
@@ -30,6 +30,9 @@ public class EmailSender : IEmailSender
|
||||
private GlobalSettings _globalSettings;
|
||||
private readonly IEmailSenderClient _emailSenderClient;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EmailSender"/> class.
|
||||
/// </summary>
|
||||
[Obsolete("Please use the non-obsolete constructor. Will be removed in V17.")]
|
||||
public EmailSender(
|
||||
ILogger<EmailSender> logger,
|
||||
@@ -39,6 +42,9 @@ public class EmailSender : IEmailSender
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EmailSender"/> class.
|
||||
/// </summary>
|
||||
[Obsolete("Please use the non-obsolete constructor. Will be removed in V17.")]
|
||||
public EmailSender(
|
||||
ILogger<EmailSender> logger,
|
||||
@@ -55,6 +61,9 @@ public class EmailSender : IEmailSender
|
||||
globalSettings.OnChange(x => _globalSettings = x);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EmailSender"/> class.
|
||||
/// </summary>
|
||||
[ActivatorUtilitiesConstructor]
|
||||
public EmailSender(
|
||||
ILogger<EmailSender> logger,
|
||||
@@ -72,19 +81,19 @@ public class EmailSender : IEmailSender
|
||||
globalSettings.OnChange(x => _globalSettings = x);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends the message async
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
/// <inheritdoc/>
|
||||
public async Task SendAsync(EmailMessage message, string emailType) =>
|
||||
await SendAsyncInternal(message, emailType, false);
|
||||
await SendAsyncInternal(message, emailType, false, null);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task SendAsync(EmailMessage message, string emailType, bool enableNotification) =>
|
||||
await SendAsyncInternal(message, emailType, enableNotification);
|
||||
await SendAsyncInternal(message, emailType, enableNotification, null);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the application should be able to send a required application email
|
||||
/// </summary>
|
||||
/// <inheritdoc/>
|
||||
public async Task SendAsync(EmailMessage message, string emailType, bool enableNotification = false, TimeSpan? expires = null) =>
|
||||
await SendAsyncInternal(message, emailType, enableNotification, expires);
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// We assume this is possible if either an event handler is registered or an smtp server is configured
|
||||
/// or a pickup directory location is configured
|
||||
@@ -93,7 +102,7 @@ public class EmailSender : IEmailSender
|
||||
|| _globalSettings.IsPickupDirectoryLocationConfigured
|
||||
|| _notificationHandlerRegistered;
|
||||
|
||||
private async Task SendAsyncInternal(EmailMessage message, string emailType, bool enableNotification)
|
||||
private async Task SendAsyncInternal(EmailMessage message, string emailType, bool enableNotification, TimeSpan? expires)
|
||||
{
|
||||
if (enableNotification)
|
||||
{
|
||||
@@ -104,7 +113,7 @@ public class EmailSender : IEmailSender
|
||||
// if a handler handled sending the email then don't continue.
|
||||
if (notification.IsHandled)
|
||||
{
|
||||
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug))
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"The email sending for {Subject} was handled by a notification handler",
|
||||
@@ -116,7 +125,7 @@ public class EmailSender : IEmailSender
|
||||
|
||||
if (!_globalSettings.IsSmtpServerConfigured && !_globalSettings.IsPickupDirectoryLocationConfigured)
|
||||
{
|
||||
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug))
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Could not send email for {Subject}. It was not handled by a notification handler and there is no SMTP configured.",
|
||||
@@ -173,7 +182,6 @@ public class EmailSender : IEmailSender
|
||||
while (true);
|
||||
}
|
||||
|
||||
await _emailSenderClient.SendAsync(message);
|
||||
await _emailSenderClient.SendAsync(message, expires);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,15 +3,25 @@ using Umbraco.Cms.Core.Models.Email;
|
||||
namespace Umbraco.Cms.Infrastructure.Mail.Interfaces
|
||||
{
|
||||
/// <summary>
|
||||
/// Client for sending an email from a MimeMessage
|
||||
/// Client for sending an email from a MimeMessage.
|
||||
/// </summary>
|
||||
public interface IEmailSenderClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends the email message
|
||||
/// Sends the email message.
|
||||
/// </summary>
|
||||
/// <param name="message"></param>
|
||||
/// <returns></returns>
|
||||
/// <param name="message">The <see cref="EmailMessage"/> to send.</param>
|
||||
[Obsolete("Please use the overload taking all parameters. Scheduled for removal in Umbraco 18.")]
|
||||
public Task SendAsync(EmailMessage message);
|
||||
|
||||
/// <summary>
|
||||
/// Sends the email message with an expiration date.
|
||||
/// </summary>
|
||||
/// <param name="message">The <see cref="EmailMessage"/> to send.</param>
|
||||
/// <param name="expires">An optional time for expiry.</param>
|
||||
public Task SendAsync(EmailMessage message, TimeSpan? expires)
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
=> SendAsync(message);
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core.IO;
|
||||
@@ -92,10 +93,15 @@ internal class PackageManifestReader : IPackageManifestReader
|
||||
packageManifests.Add(packageManifest);
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"The package manifest file {fileInfo.PhysicalPath} could not be parsed as it does not contain valid JSON. Please see the inner exception for details.", ex);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unable to load package manifest file: {FileName}", fileInfo.Name);
|
||||
throw;
|
||||
throw new InvalidOperationException(
|
||||
$"The package manifest file {fileInfo.PhysicalPath} could not be parsed due to an unexpected error. Please see the inner exception for details.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Migrations;
|
||||
using Umbraco.Cms.Core.Models.Membership;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
@@ -51,9 +52,12 @@ public class MigrationPlanExecutor : IMigrationPlanExecutor
|
||||
private readonly DistributedCache _distributedCache;
|
||||
private readonly IScopeAccessor _scopeAccessor;
|
||||
private readonly ICoreScopeProvider _scopeProvider;
|
||||
private readonly IPublishedContentTypeFactory _publishedContentTypeFactory;
|
||||
|
||||
private bool _rebuildCache;
|
||||
private bool _invalidateBackofficeUserAccess;
|
||||
|
||||
[Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
public MigrationPlanExecutor(
|
||||
ICoreScopeProvider scopeProvider,
|
||||
IScopeAccessor scopeAccessor,
|
||||
@@ -65,6 +69,33 @@ public class MigrationPlanExecutor : IMigrationPlanExecutor
|
||||
IKeyValueService keyValueService,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
AppCaches appCaches)
|
||||
: this(
|
||||
scopeProvider,
|
||||
scopeAccessor,
|
||||
loggerFactory,
|
||||
migrationBuilder,
|
||||
databaseFactory,
|
||||
databaseCacheRebuilder,
|
||||
distributedCache,
|
||||
keyValueService,
|
||||
serviceScopeFactory,
|
||||
appCaches,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IPublishedContentTypeFactory>())
|
||||
{
|
||||
}
|
||||
|
||||
public MigrationPlanExecutor(
|
||||
ICoreScopeProvider scopeProvider,
|
||||
IScopeAccessor scopeAccessor,
|
||||
ILoggerFactory loggerFactory,
|
||||
IMigrationBuilder migrationBuilder,
|
||||
IUmbracoDatabaseFactory databaseFactory,
|
||||
IDatabaseCacheRebuilder databaseCacheRebuilder,
|
||||
DistributedCache distributedCache,
|
||||
IKeyValueService keyValueService,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
AppCaches appCaches,
|
||||
IPublishedContentTypeFactory publishedContentTypeFactory)
|
||||
{
|
||||
_scopeProvider = scopeProvider;
|
||||
_scopeAccessor = scopeAccessor;
|
||||
@@ -76,6 +107,7 @@ public class MigrationPlanExecutor : IMigrationPlanExecutor
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_appCaches = appCaches;
|
||||
_distributedCache = distributedCache;
|
||||
_publishedContentTypeFactory = publishedContentTypeFactory;
|
||||
_logger = _loggerFactory.CreateLogger<MigrationPlanExecutor>();
|
||||
}
|
||||
|
||||
@@ -301,6 +333,7 @@ public class MigrationPlanExecutor : IMigrationPlanExecutor
|
||||
_appCaches.IsolatedCaches.ClearAllCaches();
|
||||
await _databaseCacheRebuilder.RebuildAsync(false);
|
||||
_distributedCache.RefreshAllPublishedSnapshot();
|
||||
_publishedContentTypeFactory.ClearDataTypeCache();
|
||||
}
|
||||
|
||||
private async Task RevokeBackofficeTokens()
|
||||
|
||||
@@ -127,5 +127,8 @@ public class UmbracoPlan : MigrationPlan
|
||||
// To 16.3.0
|
||||
To<V_16_3_0.AddRichTextEditorCapabilities>("{A917FCBC-C378-4A08-A36C-220C581A6581}");
|
||||
To<V_16_3_0.MigrateMediaTypeLabelProperties>("{FB7073AF-DFAF-4AC1-800D-91F9BD5B5238}");
|
||||
|
||||
// To 16.4.0
|
||||
To<V_16_4_0.CreateMissingTabs>("{6A7D3B80-8B64-4E41-A7C0-02EC39336E97}");
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user