Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
004d6a2
Serverside generated preview URLs
kjac Aug 28, 2025
28e9200
Add URL provider notation to UrlInfo
kjac Sep 1, 2025
64ae0ab
Merge branch 'v17/dev' into v17/feature/preview-urls
kjac Sep 2, 2025
37c0990
Change preview URL generation to happen at preview time based on prov…
kjac Sep 2, 2025
b21c2bb
Update XML docs
kjac Sep 2, 2025
7823097
Always add culture (if available) to preview URL
kjac Sep 2, 2025
6eb1071
Do not log user input (security vulnerability)
kjac Sep 2, 2025
cf2452d
Merge branch 'v17/dev' into v17/feature/preview-urls
kjac Sep 17, 2025
b6417cc
Fix typo
kjac Sep 17, 2025
5dfbef5
Merge branch 'v17/dev' into v17/feature/preview-urls
kjac Oct 2, 2025
b92aa09
Re-generate TypeScript client
leekelleher Oct 2, 2025
d45f62b
Deprecated `UmbDocumentPreviewRepository.enter()` (for v19)
leekelleher Oct 2, 2025
2992026
Adds `previewOption` extension-type
leekelleher Oct 6, 2025
e073433
Adds "default" `previewOption` kind
leekelleher Oct 6, 2025
f45cdcc
Relocated "Save and Preview" workspace action
leekelleher Oct 6, 2025
b2be9d1
Added stub for "urlProvider" `previewOption` kind
leekelleher Oct 6, 2025
68f851a
Renamed "workspace-action-default-kind.element.ts"
leekelleher Oct 6, 2025
9524216
Refactored "Save and Preview" button
leekelleher Oct 6, 2025
9356b59
Reverted `previewOption` extension-type
leekelleher Oct 6, 2025
32b7c89
Modified `saveAndPreview` Document Workspace Context
leekelleher Oct 6, 2025
8be39e7
Refactored "Save and Preview" button
leekelleher Oct 6, 2025
ae75bcc
Used `umbPeekError` to surface any errors to the user
leekelleher Oct 6, 2025
36700b5
Renamed `urlProvider` kind to `previewOption`
leekelleher Oct 7, 2025
633fa50
Relocated `urlProviderAlias` inside the `meta` property
leekelleher Oct 7, 2025
221a44c
also throw an error
leekelleher Oct 7, 2025
d00df79
Added missing `await`
leekelleher Oct 7, 2025
21a6cba
Merge remote-tracking branch 'origin/v17/dev' into v17/feature/previe…
iOvergaard Oct 7, 2025
d2105ca
Merge branch 'v17/dev' into v17/feature/preview-urls
lauraneto Oct 7, 2025
7dc5ade
Merge branch 'v17/dev' into v17/feature/preview-urls
kjac Oct 8, 2025
fe8ac03
Fix build errors after forward merge
kjac Oct 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Common.Builders;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.ViewModels.Document;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;

namespace Umbraco.Cms.Api.Management.Controllers.Document;

[ApiVersion("1.0")]
public class DocumentPreviewUrlController : DocumentControllerBase
{
private readonly IContentService _contentService;
private readonly IDocumentUrlFactory _documentUrlFactory;

public DocumentPreviewUrlController(
IContentService contentService,
IDocumentUrlFactory documentUrlFactory)
{
_contentService = contentService;
_documentUrlFactory = documentUrlFactory;
}

[MapToApiVersion("1.0")]
[HttpGet("{id:guid}/preview-url")]
[ProducesResponseType(typeof(DocumentUrlInfo), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetPreviewUrl(Guid id, string providerAlias, string? culture, string? segment)
{
IContent? content = _contentService.GetById(id);
if (content is null)
{
return NotFound(new ProblemDetailsBuilder()
.WithTitle("Document not found")
.WithDetail("The requested document did not exist.")
.Build());
}

DocumentUrlInfo? previewUrlInfo = await _documentUrlFactory.GetPreviewUrlAsync(content, providerAlias, culture, segment);
if (previewUrlInfo is null)
{
return BadRequest(new ProblemDetailsBuilder()
.WithTitle("No preview URL for document")
.WithDetail("Failed to produce a preview URL for the requested document.")
.Build());
}

return Ok(previewUrlInfo);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
namespace Umbraco.Cms.Api.Management.Controllers.Preview;

[ApiVersion("1.0")]
[Obsolete("Do not use this. Preview state is initiated implicitly by the preview URL generation. Scheduled for removal in V18.")]
public class EnterPreviewController : PreviewControllerBase
{
private readonly IPreviewService _previewService;
Expand Down
83 changes: 77 additions & 6 deletions src/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactory.cs
Original file line number Diff line number Diff line change
@@ -1,24 +1,45 @@
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Api.Management.Routing;
using Umbraco.Cms.Api.Management.ViewModels.Document;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Extensions;

namespace Umbraco.Cms.Api.Management.Factories;

public class DocumentUrlFactory : IDocumentUrlFactory
{
private readonly IPublishedUrlInfoProvider _publishedUrlInfoProvider;
private readonly UrlProviderCollection _urlProviders;
private readonly IPreviewService _previewService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
private readonly IAbsoluteUrlBuilder _absoluteUrlBuilder;
private readonly ILogger<DocumentUrlFactory> _logger;


public DocumentUrlFactory(IPublishedUrlInfoProvider publishedUrlInfoProvider)
=> _publishedUrlInfoProvider = publishedUrlInfoProvider;
public DocumentUrlFactory(
IPublishedUrlInfoProvider publishedUrlInfoProvider,
UrlProviderCollection urlProviders,
IPreviewService previewService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IAbsoluteUrlBuilder absoluteUrlBuilder,
ILogger<DocumentUrlFactory> logger)
{
_publishedUrlInfoProvider = publishedUrlInfoProvider;
_urlProviders = urlProviders;
_previewService = previewService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
_absoluteUrlBuilder = absoluteUrlBuilder;
_logger = logger;
}

public async Task<IEnumerable<DocumentUrlInfo>> CreateUrlsAsync(IContent content)
{
ISet<UrlInfo> urlInfos = await _publishedUrlInfoProvider.GetAllAsync(content);

return urlInfos
.Where(urlInfo => urlInfo.IsUrl)
.Select(urlInfo => new DocumentUrlInfo { Culture = urlInfo.Culture, Url = urlInfo.Text })
.Select(urlInfo => CreateDocumentUrlInfo(urlInfo, false))
.ToArray();
}

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

return documentUrlInfoResourceSets;
}

public async Task<DocumentUrlInfo?> GetPreviewUrlAsync(IContent content, string providerAlias, string? culture, string? segment)
{
IUrlProvider? provider = _urlProviders.FirstOrDefault(provider => provider.Alias.InvariantEquals(providerAlias));
if (provider is null)
{
_logger.LogError("Could not resolve a URL provider requested for preview - it was not registered in the URL providers collection.");
return null;
}

UrlInfo? previewUrlInfo = await provider.GetPreviewUrlAsync(content, culture, segment);
if (previewUrlInfo is null)
{
_logger.LogError("The URL provider could not generate a preview URL for content with key: {contentKey}", content.Key);
return null;
}

// must initiate preview state for internal preview URLs
if (previewUrlInfo.Url is not null && previewUrlInfo.IsExternal is false)
{
IUser? currentUser = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser;
if (currentUser is null)
{
_logger.LogError("Could not access the current backoffice user while attempting to authenticate for preview.");
return null;
}

if (await _previewService.TryEnterPreviewAsync(currentUser) is false)
{
_logger.LogError("A server error occured, could not initiate an authenticated preview state for the current user.");
return null;
}
}

return CreateDocumentUrlInfo(previewUrlInfo, previewUrlInfo.IsExternal is false);
}

private DocumentUrlInfo CreateDocumentUrlInfo(UrlInfo urlInfo, bool ensureAbsoluteUrl)
{
var url = urlInfo.Url?.ToString();
return new DocumentUrlInfo
{
Culture = urlInfo.Culture,
Url = ensureAbsoluteUrl && url is not null
? _absoluteUrlBuilder.ToAbsoluteUrl(url).ToString()
: url,
Message = urlInfo.Message,
Provider = urlInfo.Provider,
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ public interface IDocumentUrlFactory
Task<IEnumerable<DocumentUrlInfo>> CreateUrlsAsync(IContent content);

Task<IEnumerable<DocumentUrlInfoResponseModel>> CreateUrlSetsAsync(IEnumerable<IContent> contentItems);

Task<DocumentUrlInfo?> GetPreviewUrlAsync(IContent content, string providerAlias, string? culture, string? segment);
}
109 changes: 108 additions & 1 deletion src/Umbraco.Cms.Api.Management/OpenApi.json
Original file line number Diff line number Diff line change
Expand Up @@ -8659,6 +8659,101 @@
]
}
},
"/umbraco/management/api/v1/document/{id}/preview-url": {
"get": {
"tags": [
"Document"
],
"operationId": "GetDocumentByIdPreviewUrl",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
},
{
"name": "providerAlias",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "culture",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "segment",
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/DocumentUrlInfoModel"
}
]
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/ProblemDetails"
}
]
}
}
}
},
"404": {
"description": "Not Found",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/ProblemDetails"
}
]
}
}
}
},
"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/document/{id}/public-access": {
"post": {
"tags": [
Expand Down Expand Up @@ -23869,6 +23964,7 @@
"description": "The resource is protected and requires an authentication token"
}
},
"deprecated": true,
"security": [
{
"Backoffice-User": [ ]
Expand Down Expand Up @@ -39315,6 +39411,8 @@
"DocumentUrlInfoModel": {
"required": [
"culture",
"message",
"provider",
"url"
],
"type": "object",
Expand All @@ -39324,6 +39422,14 @@
"nullable": true
},
"url": {
"type": "string",
"nullable": true
},
"message": {
"type": "string",
"nullable": true
},
"provider": {
"type": "string"
}
},
Expand Down Expand Up @@ -41519,7 +41625,8 @@
"nullable": true
},
"url": {
"type": "string"
"type": "string",
"nullable": true
}
},
"additionalProperties": false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ public abstract class ContentUrlInfoBase
{
public required string? Culture { get; init; }

public required string Url { get; init; }
public required string? Url { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

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

public sealed class DocumentUrlInfo : ContentUrlInfoBase
public class DocumentUrlInfo : ContentUrlInfoBase
{
public required string? Message { get; init; }

public required string Provider { get; init; }
}
11 changes: 11 additions & 0 deletions src/Umbraco.Core/Constants-UrlProviders.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace Umbraco.Cms.Core;

public static partial class Constants
{
public static class UrlProviders
{
public const string Content = "umbDocumentUrlProvider";

public const string Media = "umbMediaUrlProvider";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ public async Task HandleAsync(ContentPublishedNotification notification, Cancell
EventMessages eventMessages = _eventMessagesFactory.Get();
foreach (var culture in successfulCultures)
{
if (urls.Where(u => u.Culture == culture || culture == "*").All(u => u.IsUrl is false))
if (urls.Where(u => u.Culture == culture || culture == "*").All(u => u.Url is null))
{
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));

Expand Down
Loading
Loading