diff --git a/SgfDevs.Tests/PublicMemberContractTests.cs b/SgfDevs.Tests/PublicMemberContractTests.cs new file mode 100644 index 0000000..d735aae --- /dev/null +++ b/SgfDevs.Tests/PublicMemberContractTests.cs @@ -0,0 +1,355 @@ +#nullable enable + +using System.Globalization; +using System.Reflection; +using System.Security.Claims; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Binders; +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SGFDevs.Controllers; +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.Strings; +using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; +using Umbraco.Cms.Web.Common.PublishedModels; +using Xunit; +using Tag = Umbraco.Cms.Web.Common.PublishedModels.Tag; + +namespace SgfDevs.Tests; + +public class PublicMemberContractTests +{ + [Fact] + public async Task Get_SerializesExplicitPublicFieldsWithoutPrivateInputSentinels() + { + var fixture = new Fixture(); + var response = await new PublicMemberController(fixture.Service).Get("JaNeDeV"); + var dto = Assert.IsType(Assert.IsType(response.Result).Value); + + Assert.Equal("JaNeDeV", fixture.LookedUpUsername); + Assert.Equal("JaneDev", dto.Username); + Assert.Equal("Jane Example", dto.Name); + Assert.Equal("Developer", dto.JobTitle); + Assert.Equal("Springfield", dto.City); + Assert.Equal("MO", dto.State); + Assert.Equal("October 2024", dto.JoinMonthLabel); + Assert.Equal("/images/pipey.jpg", dto.ProfileImageUrl); + Assert.Equal(["2024-2025 Supporting Member"], dto.Tags); + Assert.Equal("C#", Assert.Single(dto.Skills).Name); + Assert.Equal(Fixture.SkillKey.ToString(), dto.Skills[0].DirectoryFilterValue); + Assert.Equal("https://www.example.test/about", dto.WebsiteUrl); + Assert.Equal("example.test", dto.WebsiteLabel); + Assert.Equal("https://social.example.test/jane", dto.TwitterUrl); + Assert.Null(dto.LinkedInUrl); + Assert.Null(dto.FacebookUrl); + Assert.Null(dto.InstagramUrl); + Assert.Null(dto.YouTubeUrl); + Assert.True(dto.AvailableForHire); + Assert.True(dto.AvailableForContractWork); + Assert.Equal("

User biography

", dto.AboutHtml); + + var json = JsonSerializer.Serialize(dto, JsonSerializerOptions.Web); + using var document = JsonDocument.Parse(json); + Assert.Equal(new[] + { + "aboutHtml", "availableForContractWork", "availableForHire", "city", "facebookUrl", + "firstName", "instagramUrl", "jobTitle", "joinMonthLabel", "lastName", "linkedInUrl", + "name", "profileImageUrl", "skills", "state", "tags", "twitterUrl", "username", + "websiteLabel", "websiteUrl", "youTubeUrl" + }.Order(), document.RootElement.EnumerateObject().Select(p => p.Name).Order()); + Assert.Equal(["directoryFilterValue", "name"], document.RootElement.GetProperty("skills")[0] + .EnumerateObject().Select(p => p.Name).Order()); + foreach (var sentinel in Fixture.PrivateSentinels) + { + Assert.DoesNotContain(sentinel, json, StringComparison.Ordinal); + } + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("jane-dev")] + [InlineData("jane dev")] + [InlineData("Jänedev")] + [InlineData("jane/dev")] + public async Task Get_InvalidUsernameReturns404WithoutLookingUpMember(string? username) + { + var fixture = new Fixture(); + var result = await new PublicMemberController(fixture.Service).Get(username); + Assert404(result); + Assert.Null(fixture.LookedUpUsername); + } + + [Fact] + public async Task Get_WhitespaceRouteBindingReaches404InsteadOfImplicitRequired400() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddMvcCore().AddDataAnnotations(); + using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + Assert.False(options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes); + var metadataProvider = (ModelMetadataProvider)provider.GetRequiredService(); + var parameter = typeof(PublicMemberController).GetMethod(nameof(PublicMemberController.Get))!.GetParameters()[0]; + var metadata = metadataProvider.GetMetadataForParameter(parameter); + Assert.False(metadata.IsRequired); + + var action = new ActionContext(new DefaultHttpContext(), new RouteData(), + new ActionDescriptor(), new ModelStateDictionary()); + action.HttpContext.RequestServices = provider; + var values = new RouteValueProvider(BindingSource.Path, + new RouteValueDictionary { ["username"] = " " }, CultureInfo.InvariantCulture); + var binding = DefaultModelBindingContext.CreateBindingContext(action, values, metadata, null, "username"); + await new SimpleTypeModelBinder(typeof(string), provider.GetRequiredService()).BindModelAsync(binding); + Assert.True(binding.Result.IsModelSet); + Assert.Null(binding.Result.Model); + + var validator = (ObjectModelValidator)provider.GetRequiredService(); + validator.Validate(action, null, "username", binding.Result.Model, metadata); + + var fixture = new Fixture(); + var controller = new PublicMemberController(fixture.Service); + var executing = new ActionExecutingContext(action, new List(), + new Dictionary { ["username"] = binding.Result.Model }, controller); + new ModelStateInvalidFilter(provider.GetRequiredService>().Value, + provider.GetRequiredService().CreateLogger("PublicMemberBinding")).OnActionExecuting(executing); + Assert.True(action.ModelState.IsValid); + Assert.Null(executing.Result); + Assert404(await controller.Get((string?)binding.Result.Model)); + Assert.Null(fixture.LookedUpUsername); + } + + [Fact] + public async Task Get_UsernameBoundMatchesInstalledMemberSchema() + { + Assert.Equal(PublicMemberService.MaxUsernameLength, + typeof(LengthAttribute).Assembly.GetType("Umbraco.Cms.Infrastructure.Persistence.Dtos.MemberDto")! + .GetProperty("LoginName")!.GetCustomAttribute()!.Length); + var fixture = new Fixture(); + Assert404(await new PublicMemberController(fixture.Service).Get(new string('a', PublicMemberService.MaxUsernameLength + 1))); + Assert.Null(fixture.LookedUpUsername); + Assert.True(PublicMemberService.IsValidUsername(new string('a', PublicMemberService.MaxUsernameLength))); + } + + [Fact] + public async Task Get_MissingMemberReturns404() + { + var fixture = new Fixture { MissingMember = true }; + Assert404(await new PublicMemberController(fixture.Service).Get("Missing123")); + Assert.Equal("Missing123", fixture.LookedUpUsername); + } + + [Theory] + [InlineData(true, false)] + [InlineData(false, true)] + public async Task Get_MissingOrProtectedAnchorReturns404BeforeMemberLookup(bool missing, bool protectedAnchor) + { + var fixture = new Fixture { MissingAnchor = missing, ProtectedAnchor = protectedAnchor }; + Assert404(await new PublicMemberController(fixture.Service).Get("JaneDev")); + Assert.Null(fixture.LookedUpUsername); + if (protectedAnchor) + { + Assert.NotNull(fixture.CheckedPrincipal); + Assert.False(fixture.CheckedPrincipal.Identity!.IsAuthenticated); + } + } + + [Fact] + public async Task Get_DoesNotRequireDirectoryLocationOrExposeProtectedTagsAndSkills() + { + var fixture = new Fixture { ProtectTags = true }; + fixture.Properties["city"] = null; + fixture.Properties["state"] = null; + fixture.Properties.Remove("CreateDate"); + var dto = await fixture.Service.GetAsync("JaneDev"); + Assert.NotNull(dto); + Assert.Null(dto.City); + Assert.Null(dto.State); + Assert.Null(dto.JoinMonthLabel); + Assert.Empty(dto.Tags); + Assert.Empty(dto.Skills); + } + + [Theory] + [InlineData("javascript:alert(1)")] + [InlineData("data:text/html,hi")] + [InlineData("//evil.example/path")] + [InlineData("https://user:secret@example.test/")] + [InlineData("https://example.test/\npath")] + [InlineData("https://example.test/%0dpath")] + [InlineData("https:\\example.test/")] + public void Urls_RejectUnsafeLinksForWebsiteAndSocials(string value) + { + Assert.Null(PublicMemberService.GetSafeHttpUrl(value)); + Assert.Null(PublicMemberService.GetSafeHttpUrl(value, allowBareWebsite: true)); + } + + [Theory] + [InlineData("http://example.test/path", "http://example.test/path")] + [InlineData("HTTPS://example.test/", "https://example.test/")] + public void Urls_KeepHttpAndHttps(string value, string expected) => + Assert.Equal(expected, PublicMemberService.GetSafeHttpUrl(value)); + + [Fact] + public void Endpoint_HasOnly200JsonAnd404ProblemMetadata() + { + var method = typeof(PublicMemberController).GetMethod(nameof(PublicMemberController.Get))!; + var route = method.GetCustomAttribute()!; + Assert.Equal("api/v1/public/members/{username}", route.Template); + Assert.Equal("PublicMember_Get", route.Name); + Assert.Empty(method.GetCustomAttributes()); + var responses = method.GetCustomAttributes().OrderBy(a => a.StatusCode).ToArray(); + Assert.Equal([200, 404], responses.Select(a => a.StatusCode)); + Assert.Equal(typeof(PublicMemberProfileDto), responses[0].Type); + Assert.Equal(typeof(ProblemDetails), responses[1].Type); + Assert.Equal(["application/json"], ContentTypes(responses[0])); + Assert.Equal(["application/problem+json"], ContentTypes(responses[1])); + } + + private static string[] ContentTypes(ProducesResponseTypeAttribute attribute) + { + var types = new MediaTypeCollection(); + ((IApiResponseMetadataProvider)attribute).SetContentTypes(types); + return types.ToArray(); + } + + private static void Assert404(ActionResult result) + { + var problem = Assert.IsType(Assert.IsType(result.Result).Value); + Assert.Equal(404, problem.Status); + Assert.Null(problem.Detail); + Assert.Empty(problem.Extensions); + } + + private sealed class Fixture + { + public static readonly Guid SkillKey = Guid.Parse("11111111-2222-3333-4444-555555555555"); + public static readonly string[] PrivateSentinels = + ["private-email@example.test", "private-reset-token", "private-password-answer", "private-comments", "private-officer-bio", + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "private-security-stamp"]; + public Dictionary Properties { get; } = new(StringComparer.OrdinalIgnoreCase) + { + ["firstName"] = "Jane", ["lastName"] = "Example", ["jobTitle"] = "Developer", + ["city"] = "Springfield", ["state"] = "MO", ["CreateDate"] = new DateTime(2024, 10, 1), + ["aboutText"] = new HtmlEncodedString("

User biography

"), + ["websiteUrl"] = "www.example.test/about", ["twitterUrl"] = "https://social.example.test/jane", + ["linkedInUrl"] = "javascript:alert(1)", ["facebookUrl"] = "https://user:secret@example.test/", + ["instagramUrl"] = "data:text/html,hi", ["youTubeUrl"] = "https://example.test/\n", + ["availableForHire"] = true, ["availableForContractWork"] = true, + ["email"] = PrivateSentinels[0], ["resetPasswordToken"] = PrivateSentinels[1], + ["umbracoMemberPasswordRetrievalAnswer"] = PrivateSentinels[2], ["umbracoMemberComments"] = PrivateSentinels[3], + ["officerBio"] = new HtmlEncodedString(PrivateSentinels[4]) + }; + public PublicMemberService Service { get; } + public bool MissingMember { get; init; } + public bool MissingAnchor { get; init; } + public bool ProtectedAnchor { get; init; } + public bool ProtectTags { get; init; } + public string? LookedUpUsername { get; private set; } + public ClaimsPrincipal? CheckedPrincipal { get; private set; } + + public Fixture() + { + var fallback = Proxy((_, _) => null); + Tag CreateTag(string name, Guid key) => new(Content(new Dictionary { ["displayName"] = name }, key, "-1,200"), fallback); + Properties["memberTags"] = new[] { CreateTag("2024 Supporting Member", Guid.NewGuid()), CreateTag("2025 Supporting Member", Guid.NewGuid()) }; + Properties["skillsTags"] = new[] { CreateTag("C#", SkillKey) }; + var member = Content(Properties, Guid.Parse(PrivateSentinels[5]), "-1,999"); + var anchorKey = Guid.NewGuid(); + var anchor = Content([], anchorKey, "-1,100"); + var contentCache = Proxy((method, args) => + { + Assert.Equal("GetById", method.Name); + Assert.Contains(anchorKey, args!); + return MissingAnchor ? null : anchor; + }); + var context = Proxy((method, _) => method.Name == "get_Content" ? contentCache : null); + var accessor = Proxy((method, args) => + { + Assert.Equal("TryGetUmbracoContext", method.Name); + args![0] = context; + return true; + }); + var routes = Proxy((method, args) => + { + Assert.Equal("GetDocumentKeyByRoute", method.Name); + Assert.Equal("/member", args![0]); + Assert.Equal(false, args[3]); + return MissingAnchor ? null : anchorKey; + }); + var manager = Proxy((method, args) => + { + if (method.Name == "FindByNameAsync") + { + LookedUpUsername = (string)args![0]!; + return Task.FromResult(MissingMember ? null : new MemberIdentityUser + { + UserName = "JaneDev", Email = PrivateSentinels[0], SecurityStamp = PrivateSentinels[6] + }); + } + Assert.Equal("AsPublishedMember", method.Name); + return member; + }); + var protection = Proxy((_, args) => + args![0]!.Equals("-1,100") ? ProtectedAnchor : ProtectTags); + var checker = Proxy((_, args) => + { + CheckedPrincipal = Assert.IsType(args![1]); + return Task.FromResult(PublicAccessStatus.NotLoggedIn); + }); + Service = new PublicMemberService(manager, routes, accessor, + new MemberConverter(fallback, Proxy((_, _) => null)), + new MemberTagDisplayService(), new PublicContentAccessGuard(checker, protection)); + } + + private static IPublishedContent Content(Dictionary values, Guid key, string path) => + Proxy((method, args) => method.Name switch + { + "get_Key" => key, + "get_Id" => 999, + "get_Path" => path, + "get_Name" => "Private content name must not be exposed", + "GetProperty" => values.TryGetValue((string)args![0]!, out var value) + ? Proxy((m, _) => m.Name switch + { + "GetValue" => value, "HasValue" => value is not null, _ => null + }) : null, + _ => null + }); + } + + // Handwritten interface doubles, without a mocking package or a running CMS. + public class InterfaceProxy : DispatchProxy + { + public Func Handler { get; set; } = null!; + protected override object? Invoke(MethodInfo? method, object?[]? args) + { + var result = Handler(method!, args); + return result ?? (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/PublicMemberController.cs b/SgfDevs/Controllers/PublicMemberController.cs new file mode 100644 index 0000000..5c7797b --- /dev/null +++ b/SgfDevs/Controllers/PublicMemberController.cs @@ -0,0 +1,33 @@ +#nullable enable + +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 PublicMemberController : ControllerBase +{ + private readonly PublicMemberService _publicMemberService; + + public PublicMemberController(PublicMemberService publicMemberService) + { + _publicMemberService = publicMemberService; + } + + [HttpGet("api/v1/public/members/{username}", Name = "PublicMember_Get")] + [ProducesResponseType(StatusCodes.Status200OK, "application/json")] + [ProducesResponseType(StatusCodes.Status404NotFound, "application/problem+json")] + public async Task> Get(string? username) + { + var member = await _publicMemberService.GetAsync(username); + return member is null + ? NotFound(new ProblemDetails { Title = "Member profile not found.", Status = StatusCodes.Status404NotFound }) + : Ok(member); + } +} diff --git a/SgfDevs/Dev/PublicMemberService.cs b/SgfDevs/Dev/PublicMemberService.cs new file mode 100644 index 0000000..3db24ad --- /dev/null +++ b/SgfDevs/Dev/PublicMemberService.cs @@ -0,0 +1,161 @@ +#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.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Web.Common.PublishedModels; +using Umbraco.Extensions; +using Tag = Umbraco.Cms.Web.Common.PublishedModels.Tag; + +namespace SgfDevs.Dev; + +public class PublicMemberService +{ + // Umbraco 18.2's member loginName column is limited to 1000 characters. + public const int MaxUsernameLength = 1000; + private const string FallbackImage = "/images/pipey.jpg"; + private readonly IMemberManager _memberManager; + private readonly IDocumentUrlService _documentUrlService; + private readonly IUmbracoContextAccessor _contextAccessor; + private readonly MemberConverter _memberConverter; + private readonly MemberTagDisplayService _tagDisplayService; + private readonly PublicContentAccessGuard _accessGuard; + + public PublicMemberService( + IMemberManager memberManager, + IDocumentUrlService documentUrlService, + IUmbracoContextAccessor contextAccessor, + MemberConverter memberConverter, + MemberTagDisplayService tagDisplayService, + PublicContentAccessGuard accessGuard) + { + _memberManager = memberManager; + _documentUrlService = documentUrlService; + _contextAccessor = contextAccessor; + _memberConverter = memberConverter; + _tagDisplayService = tagDisplayService; + _accessGuard = accessGuard; + } + + public async Task GetAsync(string? username) + { + if (!IsValidUsername(username)) + { + return null; + } + + // Resolve the same published /member anchor as the legacy virtual page. + var anchorKey = _documentUrlService.GetDocumentKeyByRoute("/member", null, null, false); + if (anchorKey is null || !_contextAccessor.TryGetUmbracoContext(out var context)) + { + return null; + } + + var anchor = context.Content?.GetById(anchorKey.Value); + if (anchor is null || !await _accessGuard.AllowsAnonymousAsync(anchor)) + { + return null; + } + + // Keep Umbraco Identity's username normalization and case behavior. + var user = await _memberManager.FindByNameAsync(username!); + if (user is null || !IsValidUsername(user.UserName)) + { + return null; + } + + var publishedMember = _memberManager.AsPublishedMember(user); + if (publishedMember is null) + { + return null; + } + + var member = _memberConverter.FromContent(publishedMember); + return await BuildAsync(member, user.UserName!); + } + + private async Task BuildAsync(Member member, string username) + { + var publicTags = new List(); + foreach (var tag in member.MemberTags?.OfType() ?? []) + { + if (await _accessGuard.AllowsAnonymousAsync(tag)) + { + publicTags.Add(tag); + } + } + + var skills = new List(); + foreach (var skill in member.SkillsTags?.OfType() ?? []) + { + if (await _accessGuard.AllowsAnonymousAsync(skill)) + { + skills.Add(new PublicMemberSkillDto + { + Name = string.IsNullOrEmpty(skill.DisplayName) ? skill.Name : skill.DisplayName, + // SGFMemberIndexComponent indexes this GUID string in skillKeys. + DirectoryFilterValue = skill.Key.ToString() + }); + } + } + + var websiteUrl = GetSafeHttpUrl(member.WebsiteUrl, allowBareWebsite: true); + var websiteHost = websiteUrl is null ? null : new Uri(websiteUrl).Host; + var image = member.ProfileImage?.GetCropUrl(width: 800); + var joinDate = member.GetProperty("CreateDate")?.GetValue() as DateTime?; + + return new PublicMemberProfileDto + { + Username = username, + Name = $"{member.FirstName} {member.LastName}".Trim(), + FirstName = member.FirstName, + LastName = member.LastName, + JobTitle = member.JobTitle, + ProfileImageUrl = PublicHomeBuilder.GetSafePathOrHttpUrl(image) ?? FallbackImage, + Tags = _tagDisplayService.GetDisplayMemberTags(publicTags), + City = member.City, + State = member.State, + JoinMonthLabel = joinDate?.ToString("MMMM yyyy", CultureInfo.InvariantCulture), + AboutHtml = member.AboutText?.ToHtmlString(), + Skills = skills, + WebsiteUrl = websiteUrl, + WebsiteLabel = websiteHost?.StartsWith("www.", StringComparison.OrdinalIgnoreCase) == true + ? websiteHost[4..] : websiteHost, + TwitterUrl = GetSafeHttpUrl(member.TwitterUrl), + LinkedInUrl = GetSafeHttpUrl(member.LinkedInUrl), + FacebookUrl = GetSafeHttpUrl(member.FacebookUrl), + InstagramUrl = GetSafeHttpUrl(member.InstagramUrl), + YouTubeUrl = GetSafeHttpUrl(member.YouTubeUrl), + AvailableForHire = member.AvailableForHire, + AvailableForContractWork = member.AvailableForContractWork + }; + } + + internal static bool IsValidUsername(string? username) => + !string.IsNullOrEmpty(username) && username.Length <= MaxUsernameLength && + username.All(c => c is >= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9'); + + internal static string? GetSafeHttpUrl(string? value, bool allowBareWebsite = false) + { + if (string.IsNullOrWhiteSpace(value) || value.Any(c => char.IsWhiteSpace(c) || char.IsControl(c)) || + value.Contains('\\') || Uri.UnescapeDataString(value).Any(char.IsControl)) + { + return null; + } + + if (allowBareWebsite && !value.Contains(':') && !value.StartsWith('/')) + { + value = "https://" + value; + } + + return Uri.TryCreate(value, UriKind.Absolute, out var uri) && + uri.Scheme is "http" or "https" && !string.IsNullOrEmpty(uri.Host) && string.IsNullOrEmpty(uri.UserInfo) + ? uri.AbsoluteUri : null; + } +} diff --git a/SgfDevs/Program.cs b/SgfDevs/Program.cs index 8a34b5f..2ba2cd6 100644 --- a/SgfDevs/Program.cs +++ b/SgfDevs/Program.cs @@ -84,7 +84,8 @@ serverRole is ServerRole.Unknown || "Directory_GetSkillNames" or "Directory_GetSkillFilters" or "Directory_Search" or - "PublicHome_Get"; + "PublicHome_Get" or + "PublicMember_Get"; }; }); builder.Services.AddOpenApiDocumentToUi("sgf-public-v1", "SGF public API v1"); @@ -103,6 +104,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/PublicMemberProfileDto.cs b/SgfDevs/ViewModels/PublicMemberProfileDto.cs new file mode 100644 index 0000000..57996a5 --- /dev/null +++ b/SgfDevs/ViewModels/PublicMemberProfileDto.cs @@ -0,0 +1,40 @@ +#nullable enable + +using System.Collections.Generic; + +namespace SGFDevs.ViewModels; + +public class PublicMemberProfileDto +{ + public required string Username { get; init; } + public required string Name { get; init; } + public string? FirstName { get; init; } + public string? LastName { get; init; } + public string? JobTitle { get; init; } + public required string ProfileImageUrl { get; init; } + public IReadOnlyList Tags { get; init; } = []; + public string? City { get; init; } + public string? State { get; init; } + public string? JoinMonthLabel { get; init; } + + /// User-authored biography HTML. Clients must sanitize before HTML rendering. + public string? AboutHtml { get; init; } + public IReadOnlyList Skills { get; init; } = []; + public string? WebsiteUrl { get; init; } + public string? WebsiteLabel { get; init; } + public string? TwitterUrl { get; init; } + public string? LinkedInUrl { get; init; } + public string? FacebookUrl { get; init; } + public string? InstagramUrl { get; init; } + public string? YouTubeUrl { get; init; } + public bool AvailableForHire { get; init; } + public bool AvailableForContractWork { get; init; } +} + +public class PublicMemberSkillDto +{ + public required string Name { get; init; } + + /// String term accepted by the directory search skills query, not a member identifier. + public required string DirectoryFilterValue { get; init; } +}