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
91 changes: 91 additions & 0 deletions SgfDevs.Tests/PublicGroupContractTests.cs
Original file line number Diff line number Diff line change
@@ -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<IPublishedContent>((m, _) => m.Name switch
{
"get_Id" => 20, "get_Path" => "-1,10,20", _ => null
});
var root = new Groups(content, Proxy<IPublishedValueFallback>((_, _) => null));
var routes = Proxy<IDocumentUrlService>((m, a) =>
{
Assert.Equal("GetDocumentKeyByRoute", m.Name);
Assert.Equal("/groups", a![0]);
Assert.False((bool)a[3]!);
return missing ? null : key;
});
var cache = Proxy<IPublishedContentCache>((_, _) => root);
var context = Proxy<IUmbracoContext>((_, _) => cache);
var accessor = Proxy<IUmbracoContextAccessor>((_, a) => { a![0] = context; return true; });
var protection = Proxy<IPublicContentProtectionLookup>((_, a) =>
{
Assert.Equal("-1,10,20", a![0]); // includes a protected ancestor
return true;
});
var checker = Proxy<IPublicAccessChecker>((_, 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<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;
}
}
38 changes: 38 additions & 0 deletions SgfDevs/Controllers/PublicGroupController.cs
Original file line number Diff line number Diff line change
@@ -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<IReadOnlyList<PublicGroupDto>>(StatusCodes.Status200OK, "application/json")]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound, "application/problem+json")]
public async Task<ActionResult<IReadOnlyList<PublicGroupDto>>> List()
{
var result = await groups.ListAsync();
return result is null ? Missing() : Ok(result);
}

[HttpGet("api/v1/public/groups/{slug}", Name = "PublicGroups_Get")]
[ProducesResponseType<PublicGroupDto>(StatusCodes.Status200OK, "application/json")]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound, "application/problem+json")]
public async Task<ActionResult<PublicGroupDto>> 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
});
}
155 changes: 155 additions & 0 deletions SgfDevs/Dev/PublicGroupService.cs
Original file line number Diff line number Diff line change
@@ -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<PublicGroupDto> ForListing(IEnumerable<PublicGroupDto> groups) =>
groups.Where(g => g.Name != "Springfield Devs");

private async Task<Groups?> 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<IReadOnlyList<PublicGroupDto>?> ListAsync()
{
var root = await GetRootAsync();
if (root is null) return null;
var groups = new List<PublicGroupDto>();
// Published child order is the legacy list order, not alphabetical order.
foreach (var group in root.Children<Group>())
{
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<PublicGroupDto?> 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<Groups>()?.Id != root.Id || !await accessGuard.AllowsAnonymousAsync(group)) return null;
return await BuildAsync(group, includeDetail: true);
}

private async Task<PublicGroupDto?> 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<PublicGroupLeaderDto>();
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<PublicGroupSkillDto>();
if (includeDetail)
{
foreach (var tag in group.SkillTags?.OfType<Tag>() ?? [])
{
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<IReadOnlyList<PublicGroupPresentationDto>> UpcomingAsync(Group group)
{
var home = group.AncestorOrSelf<Home>();
if (home is null || !await accessGuard.AllowsAnonymousAsync(home)) return [];
var now = eventDisplay.GetCurrentTime(timeProvider.GetUtcNow());
var result = new List<PublicGroupPresentationDto>();
var presentations = home.Descendants<Presentation>()
.Where(p => p.Group?.Id == group.Id)
.Where(p => p.Parent<Event>() is { } parent && eventDisplay.IsCurrentOrUpcoming(parent.Date, now))
.OrderBy(p => p.Parent<Event>()!.Date);
foreach (var presentation in presentations)
{
var parent = presentation.Parent<Event>()!;
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;
}
}
6 changes: 3 additions & 3 deletions SgfDevs/Dev/PublicHomeService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ private async Task<IReadOnlyList<PublicHomePresentationDto>> BuildPresentationsA
return presentations;
}

private async Task<IReadOnlyList<PublicHomePresenterDto>> BuildPresentersAsync(Presentation presentation)
internal async Task<IReadOnlyList<PublicHomePresenterDto>> BuildPresentersAsync(Presentation presentation, int imageWidth = 500)
{
var presenters = new List<PublicHomePresenterDto>();

Expand All @@ -143,7 +143,7 @@ private async Task<IReadOnlyList<PublicHomePresenterDto>> 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,
Expand All @@ -155,7 +155,7 @@ private async Task<IReadOnlyList<PublicHomePresenterDto>> 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",
Expand Down
5 changes: 4 additions & 1 deletion SgfDevs/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -142,6 +144,7 @@ serverRole is ServerRole.Unknown ||
builder.Services.AddScoped<PublicHomeBuilder>();
builder.Services.AddScoped<PublicHomeService>();
builder.Services.AddScoped<PublicMemberService>();
builder.Services.AddScoped<PublicGroupService>();
builder.Services.AddScoped<MemberProfileChoices>();
builder.Services.AddScoped<MemberAvatarService>();
builder.Services.AddScoped<DirectoryHelper>();
Expand Down
48 changes: 48 additions & 0 deletions SgfDevs/ViewModels/PublicGroupDto.cs
Original file line number Diff line number Diff line change
@@ -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<PublicGroupSkillDto> Skills { get; set; } = [];
public IReadOnlyList<PublicGroupLeaderDto> Leaders { get; set; } = [];
public IReadOnlyList<PublicGroupPresentationDto> 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<string> 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<PublicHomePresenterDto> Presenters { get; set; } = [];
}
Loading