From 1ae46aeabe2414b0e3c1cf9191607da9af79dc7c Mon Sep 17 00:00:00 2001 From: Levi Zitting Date: Sun, 4 Oct 2026 12:35:16 -0500 Subject: [PATCH 1/3] feat: allow company details through native Delivery --- .../Dev/CompanyTagDeliveryValueConverter.cs | 49 +++++++++++++++++++ SgfDevs/Program.cs | 1 + SgfDevs/appsettings.json | 1 + 3 files changed, 51 insertions(+) create mode 100644 SgfDevs/Dev/CompanyTagDeliveryValueConverter.cs diff --git a/SgfDevs/Dev/CompanyTagDeliveryValueConverter.cs b/SgfDevs/Dev/CompanyTagDeliveryValueConverter.cs new file mode 100644 index 0000000..a9df677 --- /dev/null +++ b/SgfDevs/Dev/CompanyTagDeliveryValueConverter.cs @@ -0,0 +1,49 @@ +#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.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), + 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); + 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 udis) return Array.Empty(); + // Resolve published documents only. Never expand a picked node's properties or route. + return udis.OfType().Where(udi => udi.EntityType == Constants.UdiEntityType.Document) + .Select(udi => contentCache.GetById(false, udi.Guid)).OfType() + .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); diff --git a/SgfDevs/Program.cs b/SgfDevs/Program.cs index 014b604..a9c7d18 100644 --- a/SgfDevs/Program.cs +++ b/SgfDevs/Program.cs @@ -43,6 +43,7 @@ .AddWebsite() .AddDeliveryApi() .AddComposers(); +umbracoBuilder.PropertyValueConverters().Append(); LocalBootstrapTelemetryGuard.RemoveTelemetryJob(builder.Services, localBootstrapGuardResult); if (!string.IsNullOrEmpty(builder.Configuration["Umbraco:Storage:Cdn:Url"])) diff --git a/SgfDevs/appsettings.json b/SgfDevs/appsettings.json index cd66f92..1f80571 100644 --- a/SgfDevs/appsettings.json +++ b/SgfDevs/appsettings.json @@ -85,6 +85,7 @@ "events", "event", "companies", + "company", "groups", "jobs", "page" From d740369e75d3acdd9851b249c291e3375ebcb1da Mon Sep 17 00:00:00 2001 From: Levi Zitting Date: Sun, 4 Oct 2026 12:46:38 -0500 Subject: [PATCH 2/3] fix: guard native company skill projection --- README.md | 17 ++-- SgfDevs.Tests/CompanyDeliveryTests.cs | 81 +++++++++++++++++++ .../DeliveryApiConfigurationTests.cs | 22 ++++- .../Dev/CompanyTagDeliveryValueConverter.cs | 1 + 4 files changed, 111 insertions(+), 10 deletions(-) create mode 100644 SgfDevs.Tests/CompanyDeliveryTests.cs diff --git a/README.md b/README.md index e9f1764..d26e587 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/SgfDevs.Tests/CompanyDeliveryTests.cs b/SgfDevs.Tests/CompanyDeliveryTests.cs new file mode 100644 index 0000000..4752ac0 --- /dev/null +++ b/SgfDevs.Tests/CompanyDeliveryTests.cs @@ -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((_, _) => null); + Tag MakeTag(Guid key, string path) => new TestTag(Proxy((m, _) => m.Name switch + { + "get_Key" => key, "get_Name" => "Rust", "get_Path" => path, _ => null + }), fallback); + var cache = Proxy((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((_, 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((m, _) => m.Name switch + { + "get_ContentType" => Proxy((_, _) => 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(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(delivery.ConvertIntermediateToDeliveryApiObject(null!, property, + PropertyCacheLevel.None, udis, true, true))); + Assert.Empty(Assert.IsType(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 Handler { get; set; } = null!; + protected override object? Invoke(MethodInfo? method, object?[]? args) => Handler(method!, args); + } + private static T Proxy(Func handler) where T : class + { + var value = DispatchProxy.Create(); + ((InterfaceProxy)(object)value).Handler = handler; + return value; + } +} diff --git a/SgfDevs.Tests/DeliveryApiConfigurationTests.cs b/SgfDevs.Tests/DeliveryApiConfigurationTests.cs index b127d49..7aa77ba 100644 --- a/SgfDevs.Tests/DeliveryApiConfigurationTests.cs +++ b/SgfDevs.Tests/DeliveryApiConfigurationTests.cs @@ -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)] @@ -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)); } @@ -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()); diff --git a/SgfDevs/Dev/CompanyTagDeliveryValueConverter.cs b/SgfDevs/Dev/CompanyTagDeliveryValueConverter.cs index a9df677..6438f4f 100644 --- a/SgfDevs/Dev/CompanyTagDeliveryValueConverter.cs +++ b/SgfDevs/Dev/CompanyTagDeliveryValueConverter.cs @@ -6,6 +6,7 @@ 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; From 71b8f1faf93df51cab3d8e0142e63c7b42c9d6a0 Mon Sep 17 00:00:00 2001 From: Levi Zitting Date: Sun, 4 Oct 2026 13:02:54 -0500 Subject: [PATCH 3/3] fix: share singleton public protection lookup --- SgfDevs/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SgfDevs/Program.cs b/SgfDevs/Program.cs index a9c7d18..0646734 100644 --- a/SgfDevs/Program.cs +++ b/SgfDevs/Program.cs @@ -140,7 +140,7 @@ serverRole is ServerRole.Unknown || builder.Services.AddScoped(); builder.Services.AddScoped(_ => new EventDisplayService(EventSyncTimeZoneResolver.Resolve(builder.Configuration["SGFDevs:EventTimeZoneId"]))); builder.Services.AddSingleton(TimeProvider.System); -builder.Services.AddScoped(); +builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped();