Skip to content

Commit 17a5477

Browse files
kjacleekelleheriOvergaardlauraneto
authored
Serverside generated preview URLs (#20021)
* Serverside generated preview URLs * Add URL provider notation to UrlInfo * Change preview URL generation to happen at preview time based on provider alias * Update XML docs * Always add culture (if available) to preview URL * Do not log user input (security vulnerability) * Fix typo * Re-generate TypeScript client from Management API * Deprecated `UmbDocumentPreviewRepository.enter()` (for v19) Fixed TS errors Added temp stub for `getPreviewUrl` * Adds `previewOption` extension-type * Adds "default" `previewOption` kind * Relocated "Save and Preview" workspace action reworked using the "default" `previewOption` kind. * Added stub for "urlProvider" `previewOption` kind * Renamed "workspace-action-default-kind.element.ts" to a more suitable filename. Exported element so can be reused in other packages, e.g. documents, for the new "save and preview" feature. * Refactored "Save and Preview" button to work with first action's manifest/API. * Reverted `previewOption` extension-type Re-engineered to make a "urlProvider" kind for `workspaceActionMenuItem`. This is to simplify the extension point and surrounding logic. * Modified `saveAndPreview` Document Workspace Context to accept a URL Provider Alias. * Refactored "Save and Preview" button to extend `UmbWorkspaceActionElement`. This did mean exposing certain methods/properties to be overridable. * Used `umbPeekError` to surface any errors to the user * Renamed `urlProvider` kind to `previewOption` * Relocated `urlProviderAlias` inside the `meta` property * also throw an error * Added missing `await` * Fix build errors after forward merge --------- Co-authored-by: leekelleher <[email protected]> Co-authored-by: Jacob Overgaard <[email protected]> Co-authored-by: Laura Neto <[email protected]>
1 parent 74328e9 commit 17a5477

File tree

52 files changed

+785
-220
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+785
-220
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
using Asp.Versioning;
2+
using Microsoft.AspNetCore.Http;
3+
using Microsoft.AspNetCore.Mvc;
4+
using Umbraco.Cms.Api.Common.Builders;
5+
using Umbraco.Cms.Api.Management.Factories;
6+
using Umbraco.Cms.Api.Management.ViewModels.Document;
7+
using Umbraco.Cms.Core.Models;
8+
using Umbraco.Cms.Core.Services;
9+
10+
namespace Umbraco.Cms.Api.Management.Controllers.Document;
11+
12+
[ApiVersion("1.0")]
13+
public class DocumentPreviewUrlController : DocumentControllerBase
14+
{
15+
private readonly IContentService _contentService;
16+
private readonly IDocumentUrlFactory _documentUrlFactory;
17+
18+
public DocumentPreviewUrlController(
19+
IContentService contentService,
20+
IDocumentUrlFactory documentUrlFactory)
21+
{
22+
_contentService = contentService;
23+
_documentUrlFactory = documentUrlFactory;
24+
}
25+
26+
[MapToApiVersion("1.0")]
27+
[HttpGet("{id:guid}/preview-url")]
28+
[ProducesResponseType(typeof(DocumentUrlInfo), StatusCodes.Status200OK)]
29+
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
30+
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
31+
public async Task<IActionResult> GetPreviewUrl(Guid id, string providerAlias, string? culture, string? segment)
32+
{
33+
IContent? content = _contentService.GetById(id);
34+
if (content is null)
35+
{
36+
return NotFound(new ProblemDetailsBuilder()
37+
.WithTitle("Document not found")
38+
.WithDetail("The requested document did not exist.")
39+
.Build());
40+
}
41+
42+
DocumentUrlInfo? previewUrlInfo = await _documentUrlFactory.GetPreviewUrlAsync(content, providerAlias, culture, segment);
43+
if (previewUrlInfo is null)
44+
{
45+
return BadRequest(new ProblemDetailsBuilder()
46+
.WithTitle("No preview URL for document")
47+
.WithDetail("Failed to produce a preview URL for the requested document.")
48+
.Build());
49+
}
50+
51+
return Ok(previewUrlInfo);
52+
}
53+
}

src/Umbraco.Cms.Api.Management/Controllers/Preview/EnterPreviewController.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
namespace Umbraco.Cms.Api.Management.Controllers.Preview;
88

99
[ApiVersion("1.0")]
10+
[Obsolete("Do not use this. Preview state is initiated implicitly by the preview URL generation. Scheduled for removal in V18.")]
1011
public class EnterPreviewController : PreviewControllerBase
1112
{
1213
private readonly IPreviewService _previewService;

src/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactory.cs

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,45 @@
1+
using Microsoft.Extensions.Logging;
2+
using Umbraco.Cms.Api.Management.Routing;
13
using Umbraco.Cms.Api.Management.ViewModels.Document;
24
using Umbraco.Cms.Core.Models;
5+
using Umbraco.Cms.Core.Models.Membership;
36
using Umbraco.Cms.Core.Routing;
7+
using Umbraco.Cms.Core.Security;
8+
using Umbraco.Cms.Core.Services;
9+
using Umbraco.Extensions;
410

511
namespace Umbraco.Cms.Api.Management.Factories;
612

713
public class DocumentUrlFactory : IDocumentUrlFactory
814
{
915
private readonly IPublishedUrlInfoProvider _publishedUrlInfoProvider;
16+
private readonly UrlProviderCollection _urlProviders;
17+
private readonly IPreviewService _previewService;
18+
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
19+
private readonly IAbsoluteUrlBuilder _absoluteUrlBuilder;
20+
private readonly ILogger<DocumentUrlFactory> _logger;
1021

11-
12-
public DocumentUrlFactory(IPublishedUrlInfoProvider publishedUrlInfoProvider)
13-
=> _publishedUrlInfoProvider = publishedUrlInfoProvider;
22+
public DocumentUrlFactory(
23+
IPublishedUrlInfoProvider publishedUrlInfoProvider,
24+
UrlProviderCollection urlProviders,
25+
IPreviewService previewService,
26+
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
27+
IAbsoluteUrlBuilder absoluteUrlBuilder,
28+
ILogger<DocumentUrlFactory> logger)
29+
{
30+
_publishedUrlInfoProvider = publishedUrlInfoProvider;
31+
_urlProviders = urlProviders;
32+
_previewService = previewService;
33+
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
34+
_absoluteUrlBuilder = absoluteUrlBuilder;
35+
_logger = logger;
36+
}
1437

1538
public async Task<IEnumerable<DocumentUrlInfo>> CreateUrlsAsync(IContent content)
1639
{
1740
ISet<UrlInfo> urlInfos = await _publishedUrlInfoProvider.GetAllAsync(content);
18-
1941
return urlInfos
20-
.Where(urlInfo => urlInfo.IsUrl)
21-
.Select(urlInfo => new DocumentUrlInfo { Culture = urlInfo.Culture, Url = urlInfo.Text })
42+
.Select(urlInfo => CreateDocumentUrlInfo(urlInfo, false))
2243
.ToArray();
2344
}
2445

@@ -34,4 +55,54 @@ public async Task<IEnumerable<DocumentUrlInfoResponseModel>> CreateUrlSetsAsync(
3455

3556
return documentUrlInfoResourceSets;
3657
}
58+
59+
public async Task<DocumentUrlInfo?> GetPreviewUrlAsync(IContent content, string providerAlias, string? culture, string? segment)
60+
{
61+
IUrlProvider? provider = _urlProviders.FirstOrDefault(provider => provider.Alias.InvariantEquals(providerAlias));
62+
if (provider is null)
63+
{
64+
_logger.LogError("Could not resolve a URL provider requested for preview - it was not registered in the URL providers collection.");
65+
return null;
66+
}
67+
68+
UrlInfo? previewUrlInfo = await provider.GetPreviewUrlAsync(content, culture, segment);
69+
if (previewUrlInfo is null)
70+
{
71+
_logger.LogError("The URL provider could not generate a preview URL for content with key: {contentKey}", content.Key);
72+
return null;
73+
}
74+
75+
// must initiate preview state for internal preview URLs
76+
if (previewUrlInfo.Url is not null && previewUrlInfo.IsExternal is false)
77+
{
78+
IUser? currentUser = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser;
79+
if (currentUser is null)
80+
{
81+
_logger.LogError("Could not access the current backoffice user while attempting to authenticate for preview.");
82+
return null;
83+
}
84+
85+
if (await _previewService.TryEnterPreviewAsync(currentUser) is false)
86+
{
87+
_logger.LogError("A server error occured, could not initiate an authenticated preview state for the current user.");
88+
return null;
89+
}
90+
}
91+
92+
return CreateDocumentUrlInfo(previewUrlInfo, previewUrlInfo.IsExternal is false);
93+
}
94+
95+
private DocumentUrlInfo CreateDocumentUrlInfo(UrlInfo urlInfo, bool ensureAbsoluteUrl)
96+
{
97+
var url = urlInfo.Url?.ToString();
98+
return new DocumentUrlInfo
99+
{
100+
Culture = urlInfo.Culture,
101+
Url = ensureAbsoluteUrl && url is not null
102+
? _absoluteUrlBuilder.ToAbsoluteUrl(url).ToString()
103+
: url,
104+
Message = urlInfo.Message,
105+
Provider = urlInfo.Provider,
106+
};
107+
}
37108
}

src/Umbraco.Cms.Api.Management/Factories/IDocumentUrlFactory.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,6 @@ public interface IDocumentUrlFactory
88
Task<IEnumerable<DocumentUrlInfo>> CreateUrlsAsync(IContent content);
99

1010
Task<IEnumerable<DocumentUrlInfoResponseModel>> CreateUrlSetsAsync(IEnumerable<IContent> contentItems);
11+
12+
Task<DocumentUrlInfo?> GetPreviewUrlAsync(IContent content, string providerAlias, string? culture, string? segment);
1113
}

src/Umbraco.Cms.Api.Management/OpenApi.json

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8659,6 +8659,101 @@
86598659
]
86608660
}
86618661
},
8662+
"/umbraco/management/api/v1/document/{id}/preview-url": {
8663+
"get": {
8664+
"tags": [
8665+
"Document"
8666+
],
8667+
"operationId": "GetDocumentByIdPreviewUrl",
8668+
"parameters": [
8669+
{
8670+
"name": "id",
8671+
"in": "path",
8672+
"required": true,
8673+
"schema": {
8674+
"type": "string",
8675+
"format": "uuid"
8676+
}
8677+
},
8678+
{
8679+
"name": "providerAlias",
8680+
"in": "query",
8681+
"schema": {
8682+
"type": "string"
8683+
}
8684+
},
8685+
{
8686+
"name": "culture",
8687+
"in": "query",
8688+
"schema": {
8689+
"type": "string"
8690+
}
8691+
},
8692+
{
8693+
"name": "segment",
8694+
"in": "query",
8695+
"schema": {
8696+
"type": "string"
8697+
}
8698+
}
8699+
],
8700+
"responses": {
8701+
"200": {
8702+
"description": "OK",
8703+
"content": {
8704+
"application/json": {
8705+
"schema": {
8706+
"oneOf": [
8707+
{
8708+
"$ref": "#/components/schemas/DocumentUrlInfoModel"
8709+
}
8710+
]
8711+
}
8712+
}
8713+
}
8714+
},
8715+
"400": {
8716+
"description": "Bad Request",
8717+
"content": {
8718+
"application/json": {
8719+
"schema": {
8720+
"oneOf": [
8721+
{
8722+
"$ref": "#/components/schemas/ProblemDetails"
8723+
}
8724+
]
8725+
}
8726+
}
8727+
}
8728+
},
8729+
"404": {
8730+
"description": "Not Found",
8731+
"content": {
8732+
"application/json": {
8733+
"schema": {
8734+
"oneOf": [
8735+
{
8736+
"$ref": "#/components/schemas/ProblemDetails"
8737+
}
8738+
]
8739+
}
8740+
}
8741+
}
8742+
},
8743+
"401": {
8744+
"description": "The resource is protected and requires an authentication token"
8745+
},
8746+
"403": {
8747+
"description": "The authenticated user does not have access to this resource"
8748+
}
8749+
},
8750+
"security": [
8751+
{
8752+
"Backoffice-User": [ ]
8753+
}
8754+
]
8755+
}
8756+
},
86628757
"/umbraco/management/api/v1/document/{id}/public-access": {
86638758
"post": {
86648759
"tags": [
@@ -23869,6 +23964,7 @@
2386923964
"description": "The resource is protected and requires an authentication token"
2387023965
}
2387123966
},
23967+
"deprecated": true,
2387223968
"security": [
2387323969
{
2387423970
"Backoffice-User": [ ]
@@ -39315,6 +39411,8 @@
3931539411
"DocumentUrlInfoModel": {
3931639412
"required": [
3931739413
"culture",
39414+
"message",
39415+
"provider",
3931839416
"url"
3931939417
],
3932039418
"type": "object",
@@ -39324,6 +39422,14 @@
3932439422
"nullable": true
3932539423
},
3932639424
"url": {
39425+
"type": "string",
39426+
"nullable": true
39427+
},
39428+
"message": {
39429+
"type": "string",
39430+
"nullable": true
39431+
},
39432+
"provider": {
3932739433
"type": "string"
3932839434
}
3932939435
},
@@ -41519,7 +41625,8 @@
4151941625
"nullable": true
4152041626
},
4152141627
"url": {
41522-
"type": "string"
41628+
"type": "string",
41629+
"nullable": true
4152341630
}
4152441631
},
4152541632
"additionalProperties": false

src/Umbraco.Cms.Api.Management/ViewModels/Content/ContentUrlInfoBase.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@ public abstract class ContentUrlInfoBase
44
{
55
public required string? Culture { get; init; }
66

7-
public required string Url { get; init; }
7+
public required string? Url { get; init; }
88
}

src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentUrlInfo.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
namespace Umbraco.Cms.Api.Management.ViewModels.Document;
44

5-
public sealed class DocumentUrlInfo : ContentUrlInfoBase
5+
public class DocumentUrlInfo : ContentUrlInfoBase
66
{
7+
public required string? Message { get; init; }
8+
9+
public required string Provider { get; init; }
710
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
namespace Umbraco.Cms.Core;
2+
3+
public static partial class Constants
4+
{
5+
public static class UrlProviders
6+
{
7+
public const string Content = "umbDocumentUrlProvider";
8+
9+
public const string Media = "umbMediaUrlProvider";
10+
}
11+
}

src/Umbraco.Core/Events/AddUnroutableContentWarningsWhenPublishingNotificationHandler.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ public async Task HandleAsync(ContentPublishedNotification notification, Cancell
108108
EventMessages eventMessages = _eventMessagesFactory.Get();
109109
foreach (var culture in successfulCultures)
110110
{
111-
if (urls.Where(u => u.Culture == culture || culture == "*").All(u => u.IsUrl is false))
111+
if (urls.Where(u => u.Culture == culture || culture == "*").All(u => u.Url is null))
112112
{
113113
eventMessages.Add(new EventMessage("Content published", "The document does not have a URL, possibly due to a naming collision with another document. More details can be found under Info.", EventMessageType.Warning));
114114

0 commit comments

Comments
 (0)