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 new file mode 100644 index 0000000..6438f4f --- /dev/null +++ b/SgfDevs/Dev/CompanyTagDeliveryValueConverter.cs @@ -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), + 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..0646734 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"])) @@ -139,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(); 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"