diff --git a/SgfDevs.Tests/PublicGroupContractTests.cs b/SgfDevs.Tests/PublicGroupContractTests.cs new file mode 100644 index 0000000..d715bdc --- /dev/null +++ b/SgfDevs.Tests/PublicGroupContractTests.cs @@ -0,0 +1,91 @@ +#nullable enable +using System.Reflection; +using System.Security.Claims; +using System.Text.Json; +using SGFDevs.ViewModels; +using SgfDevs.Dev; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Web.Common.PublishedModels; +using Xunit; + +namespace SgfDevs.Tests; + +public class PublicGroupContractTests +{ + [Fact] + public void ListingKeepsChildOrderAndOnlyExcludesExactLegacyName() + { + var groups = new[] { "Z group", "Springfield Devs", "A group", "springfield devs" } + .Select(name => new PublicGroupDto { Name = name }); + Assert.Equal(["Z group", "A group", "springfield devs"], PublicGroupService.ForListing(groups).Select(g => g.Name)); + Assert.True(PublicGroupService.IsValidSlug("Springfield-Devs")); + foreach (var invalid in new[] { "", "../private", "a/b", "a%2fb", "a?x", "a#x", "-a", "a\\b" }) + Assert.False(PublicGroupService.IsValidSlug(invalid)); + } + + [Fact] + public async Task MissingOrProtectedRootIsNotFoundAndNeverUsesRequestIdentity() + { + foreach (var missing in new[] { false, true }) + { + var key = Guid.NewGuid(); + var content = Proxy((m, _) => m.Name switch + { + "get_Id" => 20, "get_Path" => "-1,10,20", _ => null + }); + var root = new Groups(content, Proxy((_, _) => null)); + var routes = Proxy((m, a) => + { + Assert.Equal("GetDocumentKeyByRoute", m.Name); + Assert.Equal("/groups", a![0]); + Assert.False((bool)a[3]!); + return missing ? null : key; + }); + var cache = Proxy((_, _) => root); + var context = Proxy((_, _) => cache); + var accessor = Proxy((_, a) => { a![0] = context; return true; }); + var protection = Proxy((_, a) => + { + Assert.Equal("-1,10,20", a![0]); // includes a protected ancestor + return true; + }); + var checker = Proxy((_, a) => + { + Assert.False(((ClaimsPrincipal)a![1]!).Identity!.IsAuthenticated); + return Task.FromResult(PublicAccessStatus.NotLoggedIn); + }); + var service = new PublicGroupService(routes, accessor, new PublicContentAccessGuard(checker, protection), + null!, null!, null!, new EventDisplayService(), TimeProvider.System); + Assert.Null(await service.ListAsync()); + Assert.Null(await service.GetAsync("springfield-devs")); + } + } + + [Fact] + public void ContractDoesNotHaveRawPickerOrIdentityFields() + { + var dto = new PublicGroupDto { Leaders = [new PublicGroupLeaderDto { Name = "Public name", ProfilePath = "/member/Jane" }] }; + var json = JsonSerializer.Serialize(dto, JsonSerializerOptions.Web); + using var doc = JsonDocument.Parse(json); + Assert.Equal(new[] { "imageUrl", "listLabel", "location", "name", "profilePath", "tags" }, + doc.RootElement.GetProperty("leaders")[0].EnumerateObject().Select(p => p.Name).Order()); + foreach (var type in new[] { typeof(PublicGroupDto), typeof(PublicGroupLeaderDto), typeof(PublicGroupSkillDto), typeof(PublicGroupPresentationDto) }) + Assert.DoesNotContain(type.GetProperties(), p => p.Name.Contains("Id") || p.Name.Contains("Key") || p.Name.Contains("Properties")); + } + + 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/Controllers/PublicGroupController.cs b/SgfDevs/Controllers/PublicGroupController.cs new file mode 100644 index 0000000..f16f780 --- /dev/null +++ b/SgfDevs/Controllers/PublicGroupController.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 PublicGroupController(PublicGroupService groups) : ControllerBase +{ + [HttpGet("api/v1/public/groups", Name = "PublicGroups_List")] + [ProducesResponseType>(StatusCodes.Status200OK, "application/json")] + [ProducesResponseType(StatusCodes.Status404NotFound, "application/problem+json")] + public async Task>> List() + { + var result = await groups.ListAsync(); + return result is null ? Missing() : Ok(result); + } + + [HttpGet("api/v1/public/groups/{slug}", Name = "PublicGroups_Get")] + [ProducesResponseType(StatusCodes.Status200OK, "application/json")] + [ProducesResponseType(StatusCodes.Status404NotFound, "application/problem+json")] + public async Task> Get(string? slug) + { + var result = await groups.GetAsync(slug); + return result is null ? Missing() : Ok(result); + } + + private NotFoundObjectResult Missing() => NotFound(new ProblemDetails + { + Title = "Group content not found.", Status = StatusCodes.Status404NotFound + }); +} diff --git a/SgfDevs/Dev/PublicGroupService.cs b/SgfDevs/Dev/PublicGroupService.cs new file mode 100644 index 0000000..38e45e5 --- /dev/null +++ b/SgfDevs/Dev/PublicGroupService.cs @@ -0,0 +1,155 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using SGFDevs.ViewModels; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Web.Common.PublishedModels; +using Umbraco.Extensions; +using Event = Umbraco.Cms.Web.Common.PublishedModels.Event; + +namespace SgfDevs.Dev; + +// Group Delivery values include a private member picker. Keep that alias excluded and +// project the existing published models instead of returning raw properties or picker IDs. +public class PublicGroupService( + IDocumentUrlService documentUrls, + IUmbracoContextAccessor contextAccessor, + PublicContentAccessGuard accessGuard, + MemberConverter memberConverter, + PublicMemberService publicMembers, + PublicHomeService publicHome, + EventDisplayService eventDisplay, + TimeProvider timeProvider) +{ + internal static bool IsValidSlug([System.Diagnostics.CodeAnalysis.NotNullWhen(true)] string? slug) => + !string.IsNullOrEmpty(slug) && slug.Length <= 200 && + char.IsAsciiLetterOrDigit(slug[0]) && + slug.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_'); + + internal static IEnumerable ForListing(IEnumerable groups) => + groups.Where(g => g.Name != "Springfield Devs"); + + private async Task GetRootAsync() + { + var key = documentUrls.GetDocumentKeyByRoute("/groups", null, null, false); + if (key is null || !contextAccessor.TryGetUmbracoContext(out var context)) return null; + return context.Content?.GetById(key.Value) is Groups root && + await accessGuard.AllowsAnonymousAsync(root) ? root : null; + } + + public async Task?> ListAsync() + { + var root = await GetRootAsync(); + if (root is null) return null; + var groups = new List(); + // Published child order is the legacy list order, not alphabetical order. + foreach (var group in root.Children()) + { + if (group.Name == "Springfield Devs" || !await accessGuard.AllowsAnonymousAsync(group)) continue; + var dto = await BuildAsync(group, includeDetail: false); + if (dto is not null) groups.Add(dto); + } + return ForListing(groups).ToList(); + } + + public async Task GetAsync(string? slug) + { + if (!IsValidSlug(slug)) return null; + var root = await GetRootAsync(); + if (root is null || !contextAccessor.TryGetUmbracoContext(out var context)) return null; + // Let Umbraco resolve the published URL, including umbracoUrlName and case behavior. + var key = documentUrls.GetDocumentKeyByRoute($"/groups/{slug}", null, null, false); + if (key is null || context.Content?.GetById(key.Value) is not Group group || + group.Parent()?.Id != root.Id || !await accessGuard.AllowsAnonymousAsync(group)) return null; + return await BuildAsync(group, includeDetail: true); + } + + private async Task BuildAsync(Group group, bool includeDetail) + { + var path = group.Url(); + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length != 2 || segments[0] != "groups" || !IsValidSlug(segments[1])) return null; + var leaders = new List(); + foreach (var picked in group.Leaders ?? []) + { + if (!await accessGuard.AllowsAnonymousAsync(picked)) continue; + var member = memberConverter.FromContent(picked); + // Reuse public profile lookup for the protected /member anchor, username + // normalization, published member lookup and visibility-filtered tags. + var profile = await publicMembers.GetAsync(member.Username); + if (profile is null) continue; + leaders.Add(new PublicGroupLeaderDto + { + Name = member.Name, + ListLabel = $"{profile.FirstName} {(string.IsNullOrEmpty(profile.LastName) ? string.Empty : profile.LastName[..1] + ".")}".Trim(), + Location = $"{profile.City}, {profile.State}", + ImageUrl = profile.ProfileImageUrl, + ProfilePath = $"/member/{profile.Username}", + Tags = profile.Tags + }); + } + var skills = new List(); + if (includeDetail) + { + foreach (var tag in group.SkillTags?.OfType() ?? []) + { + if (!await accessGuard.AllowsAnonymousAsync(tag)) continue; + var slug = tag.UrlSegment(); + if (!IsValidSlug(slug)) continue; + skills.Add(new PublicGroupSkillDto + { + Name = string.IsNullOrEmpty(tag.DisplayName) ? tag.Name : tag.DisplayName, + Slug = slug, + // The directory indexes published tag GUIDs in skillKeys. + DirectoryFilterValue = tag.Key.ToString() + }); + } + } + return new PublicGroupDto + { + Name = group.Name, Path = path, AboutHtml = group.AboutText?.ToHtmlString(), + ImageUrl = PublicHomeBuilder.GetSafePathOrHttpUrl(group.GroupImage?.Url()), + Location = includeDetail ? group.Location : null, + EstablishedText = includeDetail ? group.EstablishedText : null, + WebsiteUrl = PublicMemberService.GetSafeHttpUrl(group.WebsiteUrl), + TwitterUrl = PublicMemberService.GetSafeHttpUrl(group.TwitterUrl), + LinkedInUrl = PublicMemberService.GetSafeHttpUrl(group.LinkedInUrl), + FacebookUrl = PublicMemberService.GetSafeHttpUrl(group.FacebookUrl), + InstagramUrl = PublicMemberService.GetSafeHttpUrl(group.InstagramUrl), + YouTubeUrl = PublicMemberService.GetSafeHttpUrl(group.YouTubeUrl), + Leaders = leaders, Skills = skills, + UpcomingPresentations = includeDetail ? await UpcomingAsync(group) : [] + }; + } + + private async Task> UpcomingAsync(Group group) + { + var home = group.AncestorOrSelf(); + if (home is null || !await accessGuard.AllowsAnonymousAsync(home)) return []; + var now = eventDisplay.GetCurrentTime(timeProvider.GetUtcNow()); + var result = new List(); + var presentations = home.Descendants() + .Where(p => p.Group?.Id == group.Id) + .Where(p => p.Parent() is { } parent && eventDisplay.IsCurrentOrUpcoming(parent.Date, now)) + .OrderBy(p => p.Parent()!.Date); + foreach (var presentation in presentations) + { + var parent = presentation.Parent()!; + if (!await accessGuard.AllowsAnonymousAsync(parent) || !await accessGuard.AllowsAnonymousAsync(presentation)) continue; + var presenters = await publicHome.BuildPresentersAsync(presentation, 960); + // The legacy card skips presentations without a visible primary presenter. + if (presenters.Count == 0) continue; + result.Add(new PublicGroupPresentationDto + { + Title = presentation.Name, EventName = parent.Name, + StartsAtLocal = parent.Date.ToString("yyyy-MM-ddTHH:mm:ss", CultureInfo.InvariantCulture), + Presenters = presenters + }); + } + return result; + } +} diff --git a/SgfDevs/Dev/PublicHomeService.cs b/SgfDevs/Dev/PublicHomeService.cs index 97c182f..9414bc3 100644 --- a/SgfDevs/Dev/PublicHomeService.cs +++ b/SgfDevs/Dev/PublicHomeService.cs @@ -121,7 +121,7 @@ private async Task> BuildPresentationsA return presentations; } - private async Task> BuildPresentersAsync(Presentation presentation) + internal async Task> BuildPresentersAsync(Presentation presentation, int imageWidth = 500) { var presenters = new List(); @@ -143,7 +143,7 @@ private async Task> BuildPresentersAsync(P var member = _memberConverter.FromContent(presenterPicker.Member); var username = member.Username?.ToLowerInvariant() ?? string.Empty; - var image = member.ProfileImage?.GetCropUrl(width: 500) ?? FallbackMemberImage; + var image = member.ProfileImage?.GetCropUrl(width: imageWidth) ?? FallbackMemberImage; presenters.Add(new PublicHomePresenterDto { Name = member.Name, @@ -155,7 +155,7 @@ private async Task> BuildPresentersAsync(P } case NonMemberPresenter nonMemberPresenter: { - var image = nonMemberPresenter.ProfileImage?.GetCropUrl(width: 500) ?? FallbackMemberImage; + var image = nonMemberPresenter.ProfileImage?.GetCropUrl(width: imageWidth) ?? FallbackMemberImage; presenters.Add(new PublicHomePresenterDto { Name = nonMemberPresenter.PresenterName ?? "Presenter", diff --git a/SgfDevs/Program.cs b/SgfDevs/Program.cs index 4de0501..014b604 100644 --- a/SgfDevs/Program.cs +++ b/SgfDevs/Program.cs @@ -87,7 +87,9 @@ serverRole is ServerRole.Unknown || "Directory_GetSkillFilters" or "Directory_Search" or "PublicHome_Get" or - "PublicMember_Get"; + "PublicMember_Get" or + "PublicGroups_List" or + "PublicGroups_Get"; }; }); builder.Services.AddOpenApiDocumentToUi("sgf-public-v1", "SGF public API v1"); @@ -142,6 +144,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/PublicGroupDto.cs b/SgfDevs/ViewModels/PublicGroupDto.cs new file mode 100644 index 0000000..d227bdf --- /dev/null +++ b/SgfDevs/ViewModels/PublicGroupDto.cs @@ -0,0 +1,48 @@ +#nullable enable +using System.Collections.Generic; + +namespace SGFDevs.ViewModels; + +public class PublicGroupDto +{ + public string Name { get; set; } = string.Empty; + public string Path { get; set; } = string.Empty; + public string? AboutHtml { get; set; } + public string? ImageUrl { get; set; } + public string? Location { get; set; } + public string? EstablishedText { get; set; } + public string? WebsiteUrl { get; set; } + public string? TwitterUrl { get; set; } + public string? LinkedInUrl { get; set; } + public string? FacebookUrl { get; set; } + public string? InstagramUrl { get; set; } + public string? YouTubeUrl { get; set; } + public IReadOnlyList Skills { get; set; } = []; + public IReadOnlyList Leaders { get; set; } = []; + public IReadOnlyList UpcomingPresentations { get; set; } = []; +} + +public class PublicGroupSkillDto +{ + public string Name { get; set; } = string.Empty; + public string Slug { get; set; } = string.Empty; + public string DirectoryFilterValue { get; set; } = string.Empty; +} + +public class PublicGroupLeaderDto +{ + public string Name { get; set; } = string.Empty; + public string ListLabel { get; set; } = string.Empty; + public string Location { get; set; } = string.Empty; + public string ImageUrl { get; set; } = string.Empty; + public string ProfilePath { get; set; } = string.Empty; + public IReadOnlyList Tags { get; set; } = []; +} + +public class PublicGroupPresentationDto +{ + public string Title { get; set; } = string.Empty; + public string EventName { get; set; } = string.Empty; + public string StartsAtLocal { get; set; } = string.Empty; + public IReadOnlyList Presenters { get; set; } = []; +}