Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,17 +71,18 @@ For the split frontend workflow, run the CMS locally first and point the fronten

This project uses Umbraco CMS 18.2.0. The Delivery API exposes only published,
unprotected nodes with these document type aliases: `home`, `events`, `event`,
`companies`, `groups`, and `jobs`. These are public listing pages and event dates,
not the member directory or full event presentations. The existing Razor pages
continue to render unchanged.
`companies`, `company`, `groups`, `jobs`, and `page`. The existing Razor pages
continue to render unchanged. Company details use the native Delivery item endpoint.

The non-empty `Umbraco:CMS:DeliveryApi:AllowedContentTypeAliases` list excludes
every other type, including future document types. Do not empty it. Umbraco's
allowlist takes precedence over its denylist. Generic `page` nodes stay excluded
because that type also covers Member and Search Results pages. Company, group,
job, presentation, leadership, account, authentication, tag, and element types
stay excluded pending a property and reference review. The allowed types have no
member pickers, block lists, media pickers, content references, or compositions.
allowlist takes precedence over its denylist. Group, job, presentation, leadership,
account, authentication, tag, and element types stay excluded. The company-only
Delivery converter suppresses unused `companyTags` and maps `skillTags` to public
names and published tag GUID filter values. It rejects protected tag paths,
preview values and non-document pickers, even when expansion is requested.
No raw picked node properties, routes or member identities leave that converter.
Page blocks and meta remain allowed as in the content-pages layer.

Media API access and both Delivery API member authorization flows are explicitly
disabled. Umbraco excludes protected content when member authorization is disabled.
Expand Down
81 changes: 81 additions & 0 deletions SgfDevs.Tests/CompanyDeliveryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#nullable enable
using System.Reflection;
using System.Text.Json;
using SgfDevs.Dev;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.PropertyEditors.DeliveryApi;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Web.Common.PublishedModels;
using Xunit;

namespace SgfDevs.Tests;

public class CompanyDeliveryTests
{
[Fact]
public void CompanyPickersProjectPublishedPublicSkillsOnlyEvenWhenExpanded()
{
var publicKey = Guid.NewGuid();
var privateKey = Guid.NewGuid();
var fallback = Proxy<IPublishedValueFallback>((_, _) => null);
Tag MakeTag(Guid key, string path) => new TestTag(Proxy<IPublishedContent>((m, _) => m.Name switch
{
"get_Key" => key, "get_Name" => "Rust", "get_Path" => path, _ => null
}), fallback);
var cache = Proxy<IPublishedContentCache>((m, a) =>
{
Assert.Equal("GetById", m.Name);
Assert.False((bool)a![0]!);
return (Guid)a[1]! == publicKey ? MakeTag(publicKey, "-1,10,20") : MakeTag(privateKey, "-1,10,30");
});
var protection = Proxy<IPublicContentProtectionLookup>((_, a) => (string)a![0]! == "-1,10,30");
var converter = new CompanyTagDeliveryValueConverter(null!, null!, null!, null!, cache, null!, null!, protection);
IPublishedPropertyType Property(string owner, string alias) => Proxy<IPublishedPropertyType>((m, _) => m.Name switch
{
"get_ContentType" => Proxy<IPublishedContentType>((_, _) => owner), "get_Alias" => alias,
"get_EditorAlias" => Constants.PropertyEditors.Aliases.MultiNodeTreePicker, _ => null
});
var property = Property("company", "skillTags");
Assert.True(converter.IsConverter(property));
Assert.False(converter.IsConverter(Property("group", "skillTags")));
Assert.False(converter.IsConverter(Property("member", "skillTags")));
IDeliveryApiPropertyValueConverter delivery = converter;
Assert.Equal(PropertyCacheLevel.None, delivery.GetDeliveryApiPropertyCacheLevel(property));
var udis = new Udi[] { new GuidUdi(Constants.UdiEntityType.Document, publicKey),
new GuidUdi(Constants.UdiEntityType.Document, privateKey), new GuidUdi(Constants.UdiEntityType.Member, Guid.NewGuid()) };
foreach (var expanding in new[] { false, true })
{
var values = Assert.IsType<CompanySkillValue[]>(delivery.ConvertIntermediateToDeliveryApiObject(null!, property,
PropertyCacheLevel.None, udis, false, expanding));
var skill = Assert.Single(values);
Assert.Equal("Rust language", skill.Name);
Assert.Equal(publicKey.ToString(), skill.DirectoryFilterValue);
var json = JsonSerializer.Serialize(skill, JsonSerializerOptions.Web);
Assert.DoesNotContain(privateKey.ToString(), json);
using var doc = JsonDocument.Parse(json);
Assert.Equal(["directoryFilterValue", "name"], doc.RootElement.EnumerateObject().Select(p => p.Name).Order());
}
Assert.Empty(Assert.IsType<CompanySkillValue[]>(delivery.ConvertIntermediateToDeliveryApiObject(null!, property,
PropertyCacheLevel.None, udis, true, true)));
Assert.Empty(Assert.IsType<CompanySkillValue[]>(delivery.ConvertIntermediateToDeliveryApiObject(null!, Property("company", "companyTags"),
PropertyCacheLevel.None, udis, false, true)));
}

private class TestTag(IPublishedContent content, IPublishedValueFallback fallback) : Tag(content, fallback)
{
public override string DisplayName => "Rust language";
}
public class InterfaceProxy : DispatchProxy
{
public Func<MethodInfo, object?[]?, object?> Handler { get; set; } = null!;
protected override object? Invoke(MethodInfo? method, object?[]? args) => Handler(method!, args);
}
private static T Proxy<T>(Func<MethodInfo, object?[]?, object?> handler) where T : class
{
var value = DispatchProxy.Create<T, InterfaceProxy>();
((InterfaceProxy)(object)value).Handler = handler;
return value;
}
}
22 changes: 20 additions & 2 deletions SgfDevs.Tests/DeliveryApiConfigurationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ namespace SgfDevs.Tests;
public class DeliveryApiConfigurationTests
{
private static readonly string[] PublicTypes =
["home", "events", "event", "companies", "groups", "jobs", "page"];
["home", "events", "event", "companies", "company", "groups", "jobs", "page"];

[Theory]
[InlineData(false)]
Expand All @@ -37,7 +37,7 @@ public void PublicApiConfiguration_DoesNotEnablePreviewMediaOrMembers(bool devel
}

Assert.False(settings.IsAllowedContentType("futurePrivateType"));
foreach (var alias in new[] { "group", "company", "job", "leadership", "member", "markdown", "richTextEditor", "about" })
foreach (var alias in new[] { "group", "job", "leadership", "member", "markdown", "richTextEditor", "about" })
{
Assert.False(settings.IsAllowedContentType(alias));
}
Expand Down Expand Up @@ -71,6 +71,24 @@ public void AllowedSchemas_ContainOnlyReviewedPropertiesAndPageMeta()
}

Assert.Empty(root.Descendants("Composition"));
if (alias == "company")
{
Assert.Equal(["aboutText", "availableForHire", "companyTags", "facebookUrl", "featuredEmbed",
"featuredImage", "headline", "image", "instagramUrl", "isFoundingSponsor", "isSponsor",
"linkedInUrl", "location", "skillTags", "twitterUrl", "umbracoUrlName", "websiteUrl"],
properties.Select(p => p.Element("Alias")!.Value).Order(StringComparer.Ordinal));
Assert.All(properties, p => Assert.Contains(p.Element("Type")!.Value,
new[] { "Umbraco.MarkdownEditor", "Umbraco.TrueFalse", "Umbraco.TextBox", "Umbraco.TextArea",
"Umbraco.MediaPicker3", "Umbraco.MultiNodeTreePicker" }));
foreach (var picker in new[] { "CompanyTagsPicker", "SkillTagsPicker" })
{
var config = XDocument.Load(Path.Combine(ProjectDirectory, $"uSync/v18/DataTypes/{picker}.config"));
using var json = JsonDocument.Parse(config.Root!.Element("Config")!.Value);
Assert.Equal("content", json.RootElement.GetProperty("startNode").GetProperty("type").GetString());
Assert.Equal("d3afa3d9-621f-499e-bb8c-e58763df30ef", json.RootElement.GetProperty("filter").GetString());
}
continue;
}
if (alias == "event")
{
Assert.Equal(["date", "helpTextPresentations"], properties.Select(p => p.Element("Alias")!.Value).Order());
Expand Down
50 changes: 50 additions & 0 deletions SgfDevs/Dev/CompanyTagDeliveryValueConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.PropertyEditors.DeliveryApi;
using Umbraco.Cms.Core.PropertyEditors.ValueConverters;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Web.Common.PublishedModels;

namespace SgfDevs.Dev;

// Keep native model conversion for Razor. Only company Delivery picker values change.
public class CompanyTagDeliveryValueConverter(
IUmbracoContextAccessor context, IMemberService members, IApiContentBuilder contentBuilder,
IApiMediaBuilder mediaBuilder, IPublishedContentCache contentCache,
IPublishedMediaCache mediaCache, IPublishedMemberCache memberCache,
IPublicContentProtectionLookup protection)
: MultiNodeTreePickerValueConverter(context, members, contentBuilder, mediaBuilder, contentCache, mediaCache, memberCache),

Check warning on line 24 in SgfDevs/Dev/CompanyTagDeliveryValueConverter.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Parameter 'IPublishedContentCache contentCache' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 24 in SgfDevs/Dev/CompanyTagDeliveryValueConverter.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Parameter 'IPublishedContentCache contentCache' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.
IDeliveryApiPropertyValueConverter
{
public override bool IsConverter(IPublishedPropertyType propertyType) =>
propertyType.ContentType?.Alias == "company" &&
propertyType.Alias is "skillTags" or "companyTags" && base.IsConverter(propertyType);

// Do not retain a projection after a protection change.
public new PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => PropertyCacheLevel.None;
public new PropertyCacheLevel GetDeliveryApiPropertyCacheLevelForExpansion(IPublishedPropertyType propertyType) => PropertyCacheLevel.None;
public new Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) => typeof(IEnumerable<CompanySkillValue>);
public new object ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType,
PropertyCacheLevel referenceCacheLevel, object? inter, bool preview, bool expanding)
{
if (preview || propertyType.Alias != "skillTags" || inter is not IEnumerable<Udi> udis) return Array.Empty<CompanySkillValue>();
// Resolve published documents only. Never expand a picked node's properties or route.
return udis.OfType<GuidUdi>().Where(udi => udi.EntityType == Constants.UdiEntityType.Document)
.Select(udi => contentCache.GetById(false, udi.Guid)).OfType<Tag>()
.Where(tag => !protection.IsProtectedPath(tag.Path))
.Select(Project).ToArray();
}

internal static CompanySkillValue Project(Tag tag) => new(
string.IsNullOrEmpty(tag.DisplayName) ? tag.Name : tag.DisplayName, tag.Key.ToString());
}

public record CompanySkillValue(string Name, string DirectoryFilterValue);
3 changes: 2 additions & 1 deletion SgfDevs/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
.AddWebsite()
.AddDeliveryApi()
.AddComposers();
umbracoBuilder.PropertyValueConverters().Append<CompanyTagDeliveryValueConverter>();
LocalBootstrapTelemetryGuard.RemoveTelemetryJob(builder.Services, localBootstrapGuardResult);

if (!string.IsNullOrEmpty(builder.Configuration["Umbraco:Storage:Cdn:Url"]))
Expand Down Expand Up @@ -139,7 +140,7 @@ serverRole is ServerRole.Unknown ||
builder.Services.AddScoped<PresentationPresenterDisplayService>();
builder.Services.AddScoped(_ => new EventDisplayService(EventSyncTimeZoneResolver.Resolve(builder.Configuration["SGFDevs:EventTimeZoneId"])));
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScoped<IPublicContentProtectionLookup, PublicContentProtectionLookup>();
builder.Services.AddSingleton<IPublicContentProtectionLookup, PublicContentProtectionLookup>();
builder.Services.AddScoped<PublicContentAccessGuard>();
builder.Services.AddScoped<PublicHomeBuilder>();
builder.Services.AddScoped<PublicHomeService>();
Expand Down
1 change: 1 addition & 0 deletions SgfDevs/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
"events",
"event",
"companies",
"company",
"groups",
"jobs",
"page"
Expand Down
Loading