From 9bbdff3766241b0f1833e15620390e2e76ffeb3a Mon Sep 17 00:00:00 2001 From: Levi Zitting Date: Sun, 4 Oct 2026 13:27:15 -0500 Subject: [PATCH 1/3] feat: restore public job projections --- SgfDevs.Tests/PublicJobContractTests.cs | 145 +++++++++++++++++++++ SgfDevs/Controllers/PublicJobController.cs | 38 ++++++ SgfDevs/Dev/PublicJobService.cs | 96 ++++++++++++++ SgfDevs/Program.cs | 5 +- SgfDevs/ViewModels/PublicJobDto.cs | 18 +++ jobs-pages.md | 7 + 6 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 SgfDevs.Tests/PublicJobContractTests.cs create mode 100644 SgfDevs/Controllers/PublicJobController.cs create mode 100644 SgfDevs/Dev/PublicJobService.cs create mode 100644 SgfDevs/ViewModels/PublicJobDto.cs create mode 100644 jobs-pages.md diff --git a/SgfDevs.Tests/PublicJobContractTests.cs b/SgfDevs.Tests/PublicJobContractTests.cs new file mode 100644 index 0000000..1d73787 --- /dev/null +++ b/SgfDevs.Tests/PublicJobContractTests.cs @@ -0,0 +1,145 @@ +#nullable enable +using System.Reflection; +using System.Security.Claims; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using SGFDevs.Controllers; +using SGFDevs.ViewModels; +using SgfDevs.Dev; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.Navigation; +using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Web.Common.PublishedModels; +using Xunit; + +namespace SgfDevs.Tests; + +public class PublicJobContractTests +{ + [Fact] + public async Task MissingPreviewAndProtectedAncestorsNeverReturnFakeSuccess() + { + foreach (var mode in new[] { "missing", "preview", "home", "company", "job", "wrong-parent" }) + { + var f = new Fixture(mode); + Assert.IsType((await new PublicJobController(f.Service).Get("custom-company", "custom-job")).Result); + if (mode is "missing" or "preview" or "home") Assert.Null(await f.Service.ListAsync()); + else Assert.Empty((await f.Service.ListAsync())!); + } + } + + [Fact] + public async Task ListingKeepsNativeDescendantOrderWithoutExpirySortOrLimitAndDetailUsesResolvedParent() + { + var f = new Fixture(); + var rows = (await f.Service.ListAsync())!; + Assert.Equal(["Z old job", "A new job"], rows.Select(j => j.Name)); + Assert.Equal("January 2, 2001", rows[0].Posted); + Assert.All(rows, j => { Assert.Equal("Parent company", j.CompanyName); Assert.Null(j.DescriptionHtml); Assert.Empty(j.Skills); }); + var job = (await f.Service.GetAsync("custom-company", "custom-job"))!; + Assert.Equal("/companies/custom-company/custom-job/", job.Path); + Assert.Equal("Remote", job.Location); + Assert.Equal("Full time", job.EmploymentType); + Assert.Equal("$90,000", job.Compensation); + Assert.Equal(["Visible skill", "Fallback skill", "Visible skill"], job.Skills); + Assert.Equal("

Public job description

", job.DescriptionHtml); + Assert.Equal("https://example.test/apply", job.ApplyUrl); + Assert.DoesNotContain("private-", JsonSerializer.Serialize(job)); + } + + [Fact] + public async Task InvalidSlugsMissingJobsAndUnsafeApplyKeepNarrowPublicContract() + { + var f = new Fixture(); + foreach (var bad in new[] { "", "../private", "a/b", "a%2fb", "a?preview=true", "a\\b" }) + Assert.Null(await f.Service.GetAsync("custom-company", bad)); + Assert.Null(await f.Service.GetAsync("custom-company", "missing-job")); + Assert.Null((await new Fixture("unsafe-apply").Service.GetAsync("custom-company", "custom-job"))!.ApplyUrl); + Assert.False(PublicJobService.IsJobPath("/companies/x/y//")); + Assert.DoesNotContain(typeof(PublicJobDto).GetProperties(), p => p.Name.Contains("Id") || p.Name.Contains("Key") || p.Name.Contains("Tags")); + } + + private sealed class Fixture + { + public PublicJobService Service { get; } + public Fixture(string mode = "") + { + var fallback = Proxy((_, _) => null); + var homeKey = Guid.NewGuid(); var pageKey = Guid.NewGuid(); var companyKey = Guid.NewGuid(); + var jobKey = Guid.NewGuid(); var nextKey = Guid.NewGuid(); + var home = Content("Home", homeKey, "-1,10", "home"); + var page = new Jobs(Content("Jobs", pageKey, "-1,10,20", "jobs"), fallback); + var company = new Company(Content("Parent company", companyKey, "-1,10,30,40", "company"), fallback); + Tag Skill(string name, string display, string path) => new(Content(name, Guid.NewGuid(), path, "tag", new() { ["displayName"] = display }), fallback); + var skill = Skill("Skill", "Visible skill", "-1,10,80"); + var hidden = Skill("private-name", "private-display", "-1,secret,81"); + var job = new Job(Content("Z old job", jobKey, "-1,10,30,40,50", "job", new() { + ["location"] = "Remote", ["employmentType"] = "Full time", ["compensation"] = "$90,000", + ["jobDescription"] = new HtmlEncodedString("

Public job description

"), + ["applyURL"] = mode == "unsafe-apply" ? "javascript:private()" : "https://example.test/apply", + ["skillTags"] = new[] { skill, hidden, Skill("Fallback skill", "", "-1,10,82"), skill }, + ["jobTags"] = new[] { "private-picker" }, ["email"] = "private-email" + }), fallback); + var next = new Job(Content("A new job", nextKey, "-1,10,30,40,51", "job"), fallback); + var documents = new Dictionary { [homeKey] = home, [pageKey] = page, [companyKey] = company, [jobKey] = job, [nextKey] = next }; + var routes = Proxy((_, a) => { + Assert.False((bool)a![3]!); + if (mode == "missing") return null; + return (string)a[0]! switch { "/jobs" => pageKey, "/companies/custom-company" => companyKey, + "/companies/custom-company/custom-job" => jobKey, _ => null }; + }); + var cache = Proxy((_, a) => { Assert.Equal(false, a![0]); return documents[(Guid)a[1]!]; }); + var context = Proxy((m, _) => m.Name switch { "get_Content" => cache, "get_InPreviewMode" => mode == "preview", _ => null }); + var accessor = Proxy((_, a) => { a![0] = context; return true; }); + var navigation = Proxy((m, a) => { + var key = (Guid)a![0]!; + switch (m.Name) { + case "TryGetParentKey": a[1] = key == homeKey ? null : key == pageKey || key == companyKey || mode == "wrong-parent" ? homeKey : companyKey; return true; + case "TryGetAncestorsKeys": a[1] = new[] { homeKey }; return true; + case "TryGetDescendantsKeys": a[1] = new[] { jobKey, nextKey }; return true; + case "TryGetDescendantsKeysOfType": a[2] = new[] { jobKey, nextKey }; return true; + default: throw new Exception("Unexpected navigation " + m.Name); + } + }); + var published = Proxy((_, a) => ((IEnumerable)a![0]!).Select(k => documents[k])); + var urls = Proxy((_, a) => { + var key = a![0] is IPublishedContent c ? c.Key : (Guid)a[0]!; + return key == companyKey ? "/companies/custom-company/" : key == jobKey ? "/companies/custom-company/custom-job/" : "/companies/custom-company/next-job/"; + }); + var protection = Proxy((_, a) => { + var path = (string)a![0]!; + return path.Contains("secret") || mode == "home" && path.Contains(",10") || + mode == "company" && path.Contains(",40") || mode == "job" && (path.EndsWith(",50") || path.EndsWith(",51")); + }); + var checker = Proxy((_, a) => { + Assert.False(((ClaimsPrincipal)a![1]!).Identity!.IsAuthenticated); + return Task.FromResult(PublicAccessStatus.NotLoggedIn); + }); + Service = new(routes, accessor, navigation, published, urls, new(checker, protection)); + } + private static IPublishedContent Content(string name, Guid key, string path, string alias, Dictionary? values = null) => + Proxy((m, a) => m.Name switch { + "get_Id" => 900, "get_Key" => key, "get_Path" => path, "get_Name" => name, + "get_CreateDate" => name.StartsWith("Z") ? new DateTime(2001, 1, 2) : new DateTime(2026, 9, 3), + "get_ItemType" => PublishedItemType.Content, + "get_ContentType" => Proxy((p, _) => p.Name switch { "get_Alias" => alias, "get_ItemType" => PublishedItemType.Content, _ => null }), + "GetProperty" => values is not null && values.TryGetValue((string)a![0]!, out var v) ? Proxy((p, _) => p.Name switch { + "GetValue" => v, "HasValue" => v is not null, _ => null + }) : null, _ => null + }); + } + public class InterfaceProxy : DispatchProxy + { + public Func Handler { get; set; } = null!; + protected override object? Invoke(MethodInfo? method, object?[]? args) => Handler(method!, args) ?? + (method!.ReturnType.IsValueType ? Activator.CreateInstance(method.ReturnType) : null); + } + private static T Proxy(Func handler) where T : class { + var proxy = DispatchProxy.Create(); ((InterfaceProxy)(object)proxy).Handler = handler; return proxy; + } +} diff --git a/SgfDevs/Controllers/PublicJobController.cs b/SgfDevs/Controllers/PublicJobController.cs new file mode 100644 index 0000000..b36d0dd --- /dev/null +++ b/SgfDevs/Controllers/PublicJobController.cs @@ -0,0 +1,38 @@ +#nullable enable +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using SGFDevs.ViewModels; +using SgfDevs.Dev; + +namespace SGFDevs.Controllers; + +[ApiController] +[AllowAnonymous] +public class PublicJobController(PublicJobService jobs) : ControllerBase +{ + [HttpGet("api/v1/public/jobs", Name = "PublicJobs_List")] + [ProducesResponseType>(StatusCodes.Status200OK, "application/json")] + [ProducesResponseType(StatusCodes.Status404NotFound, "application/problem+json")] + public async Task>> List() + { + var result = await jobs.ListAsync(); + return result is null ? Missing() : Ok(result); + } + + [HttpGet("api/v1/public/jobs/{company}/{job}", Name = "PublicJobs_Get")] + [ProducesResponseType(StatusCodes.Status200OK, "application/json")] + [ProducesResponseType(StatusCodes.Status404NotFound, "application/problem+json")] + public async Task> Get(string? company, string? job) + { + var result = await jobs.GetAsync(company, job); + return result is null ? Missing() : Ok(result); + } + + private NotFoundObjectResult Missing() => NotFound(new ProblemDetails + { + Title = "Job content not found.", Status = StatusCodes.Status404NotFound + }); +} diff --git a/SgfDevs/Dev/PublicJobService.cs b/SgfDevs/Dev/PublicJobService.cs new file mode 100644 index 0000000..26bc9ef --- /dev/null +++ b/SgfDevs/Dev/PublicJobService.cs @@ -0,0 +1,96 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading.Tasks; +using SGFDevs.ViewModels; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.Navigation; +using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Web.Common.PublishedModels; +using Umbraco.Extensions; + +namespace SgfDevs.Dev; + +// Native Delivery has neither the legacy root-descendant order nor parent company +// fields. Leave raw job/tag pickers excluded and project only the Razor page values. +public class PublicJobService( + IDocumentUrlService documentUrls, IUmbracoContextAccessor contexts, + INavigationQueryService navigation, IPublishedStatusFilteringService published, + IPublishedUrlProvider urls, PublicContentAccessGuard access) +{ + internal static bool IsJobPath(string? path) + { + var parts = path?.Split('/', StringSplitOptions.RemoveEmptyEntries); + return path is not null && path.StartsWith("/companies/", StringComparison.Ordinal) && + parts is { Length: 3 } && PublicGroupService.IsValidSlug(parts[1]) && + PublicGroupService.IsValidSlug(parts[2]) && + (path == $"/companies/{parts[1]}/{parts[2]}" || path == $"/companies/{parts[1]}/{parts[2]}/"); + } + + public async Task?> ListAsync() + { + var key = documentUrls.GetDocumentKeyByRoute("/jobs", null, null, false); + if (key is null || !contexts.TryGetUmbracoContext(out var context) || context.InPreviewMode || + context.Content?.GetById(false, key.Value) is not Jobs page || + !await access.AllowsAnonymousAsync(page)) return null; + var root = page.Root(navigation, published); + if (root is null || !await access.AllowsAnonymousAsync(root)) return null; + var result = new List(); + // Exactly the legacy Root().Descendants() sequence. No date/type filter, + // explicit sort, pagination or limit. + foreach (var job in root.Descendants(navigation, published)) + { + var dto = await ProjectAsync(job, false); + if (dto is not null) result.Add(dto); + } + return result; + } + + public async Task GetAsync(string? company, string? job) + { + if (!PublicGroupService.IsValidSlug(company) || !PublicGroupService.IsValidSlug(job) || + !contexts.TryGetUmbracoContext(out var context) || context.InPreviewMode) return null; + var companyKey = documentUrls.GetDocumentKeyByRoute($"/companies/{company}", null, null, false); + var jobKey = documentUrls.GetDocumentKeyByRoute($"/companies/{company}/{job}", null, null, false); + if (companyKey is null || jobKey is null || + context.Content?.GetById(false, companyKey.Value) is not Company parent || + context.Content.GetById(false, jobKey.Value) is not Job document || + document.Parent(navigation, published)?.Key != parent.Key || + !await access.AllowsAnonymousAsync(parent)) return null; + return await ProjectAsync(document, true); + } + + internal static string? SafeApplyUrl(string? value) + { + if (value is not null && value.StartsWith('/') && !value.StartsWith("//", StringComparison.Ordinal) && + !value.Contains('\\') && !value.Contains('%') && !System.Linq.Enumerable.Any(value, c => char.IsWhiteSpace(c) || char.IsControl(c))) + return value; + return PublicMemberService.GetSafeHttpUrl(value); + } + + internal async Task ProjectAsync(Job job, bool detail) + { + var company = job.Parent(navigation, published); + if (company is null || !await access.AllowsAnonymousAsync(company) || + !await access.AllowsAnonymousAsync(job)) return null; + var path = job.Url(urls); + var companyPath = company.Url(urls).TrimEnd('/'); + if (!IsJobPath(path) || !path.StartsWith(companyPath + "/", StringComparison.OrdinalIgnoreCase)) return null; + var skills = new List(); + if (detail) + foreach (var tag in job.SkillTags ?? []) + if (tag is Tag skill && await access.AllowsAnonymousAsync(skill)) + skills.Add(string.IsNullOrEmpty(skill.DisplayName) ? skill.Name : skill.DisplayName); + return new PublicJobDto + { + Name = job.Name, CompanyName = company.Name, Path = path, + Location = job.Location, EmploymentType = job.EmploymentType, Compensation = job.Compensation, + Posted = job.CreateDate.ToString("MMMM d, yyyy", CultureInfo.InvariantCulture), + DescriptionHtml = detail ? job.JobDescription?.ToHtmlString() : null, + ApplyUrl = detail ? SafeApplyUrl(job.ApplyUrl) : null, + Skills = skills + }; + } +} diff --git a/SgfDevs/Program.cs b/SgfDevs/Program.cs index f6a722f..299a510 100644 --- a/SgfDevs/Program.cs +++ b/SgfDevs/Program.cs @@ -91,7 +91,9 @@ serverRole is ServerRole.Unknown || "PublicMember_Get" or "PublicGroups_List" or "PublicGroups_Get" or - "PublicLeadership_Get"; + "PublicLeadership_Get" or + "PublicJobs_List" or + "PublicJobs_Get"; }; }); builder.Services.AddOpenApiDocumentToUi("sgf-public-v1", "SGF public API v1"); @@ -148,6 +150,7 @@ serverRole is ServerRole.Unknown || builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/SgfDevs/ViewModels/PublicJobDto.cs b/SgfDevs/ViewModels/PublicJobDto.cs new file mode 100644 index 0000000..0f3c622 --- /dev/null +++ b/SgfDevs/ViewModels/PublicJobDto.cs @@ -0,0 +1,18 @@ +#nullable enable +using System.Collections.Generic; + +namespace SGFDevs.ViewModels; + +public class PublicJobDto +{ + public string Name { get; init; } = string.Empty; + public string CompanyName { get; init; } = string.Empty; + public string Path { get; init; } = string.Empty; + public string? Location { get; init; } + public string? EmploymentType { get; init; } + public string? Compensation { get; init; } + public string Posted { get; init; } = string.Empty; + public string? DescriptionHtml { get; init; } + public string? ApplyUrl { get; init; } + public IReadOnlyList Skills { get; init; } = []; +} diff --git a/jobs-pages.md b/jobs-pages.md new file mode 100644 index 0000000..400f149 --- /dev/null +++ b/jobs-pages.md @@ -0,0 +1,7 @@ +# Jobs pages + +GET `/api/v1/public/jobs` follows the published `/jobs` page's `Root().Descendants()` sequence, matching the legacy table. No sort, expiry filter, pagination or result limit is added. The parent company's name and native published job URL are projected with location, employment type, compensation and the invariant `MMMM d, yyyy` creation date. + +GET `/api/v1/public/jobs/{company}/{job}` resolves both actual published routes through `IDocumentUrlService`, including custom URL names. The resolved job must have the resolved Company as its direct parent. Preview is disabled, and anonymous protection checks include content ancestor paths. Missing, mismatched or protected content returns 404. + +Native Delivery cannot supply this ordered descendant list with its parent-company shape. This service uses native navigation, published filtering, URL and model value services instead. Existing raw job/tag exclusions, company converters, protected content rules and Delivery API-key policy remain unchanged. Only detail includes description HTML, a safe apply URL and visible skill labels. No member, picker ID, unused jobTags or banner values are returned. The frontend sanitizes description HTML and apply links before rendering. From 5f2737878b2466503184d1e066fcfadf325c91a9 Mon Sep 17 00:00:00 2001 From: Levi Zitting Date: Sun, 4 Oct 2026 13:29:01 -0500 Subject: [PATCH 2/3] fix: inject native document navigation --- SgfDevs.Tests/PublicJobContractTests.cs | 2 +- SgfDevs/Dev/PublicJobService.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/SgfDevs.Tests/PublicJobContractTests.cs b/SgfDevs.Tests/PublicJobContractTests.cs index 1d73787..f4bfbfb 100644 --- a/SgfDevs.Tests/PublicJobContractTests.cs +++ b/SgfDevs.Tests/PublicJobContractTests.cs @@ -96,7 +96,7 @@ public Fixture(string mode = "") var cache = Proxy((_, a) => { Assert.Equal(false, a![0]); return documents[(Guid)a[1]!]; }); var context = Proxy((m, _) => m.Name switch { "get_Content" => cache, "get_InPreviewMode" => mode == "preview", _ => null }); var accessor = Proxy((_, a) => { a![0] = context; return true; }); - var navigation = Proxy((m, a) => { + var navigation = Proxy((m, a) => { var key = (Guid)a![0]!; switch (m.Name) { case "TryGetParentKey": a[1] = key == homeKey ? null : key == pageKey || key == companyKey || mode == "wrong-parent" ? homeKey : companyKey; return true; diff --git a/SgfDevs/Dev/PublicJobService.cs b/SgfDevs/Dev/PublicJobService.cs index 26bc9ef..00d735a 100644 --- a/SgfDevs/Dev/PublicJobService.cs +++ b/SgfDevs/Dev/PublicJobService.cs @@ -17,7 +17,7 @@ namespace SgfDevs.Dev; // fields. Leave raw job/tag pickers excluded and project only the Razor page values. public class PublicJobService( IDocumentUrlService documentUrls, IUmbracoContextAccessor contexts, - INavigationQueryService navigation, IPublishedStatusFilteringService published, + IDocumentNavigationQueryService navigation, IPublishedStatusFilteringService published, IPublishedUrlProvider urls, PublicContentAccessGuard access) { internal static bool IsJobPath(string? path) From d4ca400351862b680e2dbf800562265e927eda26 Mon Sep 17 00:00:00 2001 From: Levi Zitting Date: Sun, 4 Oct 2026 13:29:47 -0500 Subject: [PATCH 3/3] fix: use published content filtering registration --- SgfDevs.Tests/PublicJobContractTests.cs | 2 +- SgfDevs/Dev/PublicJobService.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/SgfDevs.Tests/PublicJobContractTests.cs b/SgfDevs.Tests/PublicJobContractTests.cs index f4bfbfb..4d575d7 100644 --- a/SgfDevs.Tests/PublicJobContractTests.cs +++ b/SgfDevs.Tests/PublicJobContractTests.cs @@ -106,7 +106,7 @@ public Fixture(string mode = "") default: throw new Exception("Unexpected navigation " + m.Name); } }); - var published = Proxy((_, a) => ((IEnumerable)a![0]!).Select(k => documents[k])); + var published = Proxy((_, a) => ((IEnumerable)a![0]!).Select(k => documents[k])); var urls = Proxy((_, a) => { var key = a![0] is IPublishedContent c ? c.Key : (Guid)a[0]!; return key == companyKey ? "/companies/custom-company/" : key == jobKey ? "/companies/custom-company/custom-job/" : "/companies/custom-company/next-job/"; diff --git a/SgfDevs/Dev/PublicJobService.cs b/SgfDevs/Dev/PublicJobService.cs index 00d735a..9bb783e 100644 --- a/SgfDevs/Dev/PublicJobService.cs +++ b/SgfDevs/Dev/PublicJobService.cs @@ -17,7 +17,7 @@ namespace SgfDevs.Dev; // fields. Leave raw job/tag pickers excluded and project only the Razor page values. public class PublicJobService( IDocumentUrlService documentUrls, IUmbracoContextAccessor contexts, - IDocumentNavigationQueryService navigation, IPublishedStatusFilteringService published, + IDocumentNavigationQueryService navigation, IPublishedContentStatusFilteringService published, IPublishedUrlProvider urls, PublicContentAccessGuard access) { internal static bool IsJobPath(string? path)