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
92 changes: 92 additions & 0 deletions SgfDevs.Tests/NewsletterTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
#nullable enable

using System.Net;
using System.Net.Http;
using System.Text;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using SGFDevs.Controllers;
using SgfDevs.Dev;
using Xunit;

namespace SgfDevs.Tests;

public class NewsletterTests
{
[Theory]
[InlineData("bad", "", true, 0, 400)]
[InlineData("person@example.test", " ", true, 0, 400)]
[InlineData("person@example.test", "bot", true, 0, 400)]
[InlineData("person@example.test", "", false, 0, 503)]
[InlineData("person@example.test", "", true, 1, 200)]
public async Task SignupValidatesBeforeAnyProviderRequest(
string email, string name, bool enabled, int calls, int status)
{
using var handler = new Provider(HttpStatusCode.OK, "{\"data\":true}");
var controller = Controller(handler, enabled ? "http://trusted-internal-service/subscribe" : "", enabled ? "configured-list" : "");
var result = await controller.Signup(new(email, name));
Assert.Equal(status, Assert.IsAssignableFrom<ObjectResult>(result.Result).StatusCode);
Assert.Equal(calls, handler.Calls);
Assert.Equal(status == 200, Assert.IsType<NewsletterSignupResult>(
Assert.IsAssignableFrom<ObjectResult>(result.Result).Value).Accepted);
}

[Theory]
[InlineData(200, "{\"data\":false}")]
[InlineData(503, "{\"data\":true}")]
[InlineData(200, "not json")]
public async Task FailedProviderRequestIsNotAccepted(int status, string body)
{
using var handler = new Provider((HttpStatusCode)status, body);
var result = await Controller(handler).Signup(new("person@example.test", ""));
Assert.Equal(503, Assert.IsType<ObjectResult>(result.Result).StatusCode);
Assert.Equal(1, handler.Calls);
}

[Fact]
public async Task MissingListAndProviderExceptionFailSafely()
{
using var handler = new Provider(HttpStatusCode.OK, "{\"data\":true}");
var disabled = await Controller(handler, list: "").Signup(new("person@example.test", ""));
Assert.Equal(503, Assert.IsType<ObjectResult>(disabled.Result).StatusCode);
Assert.Equal(0, handler.Calls);
handler.Throw = true;
var failed = await Controller(handler).Signup(new("person@example.test", ""));
Assert.Equal(503, Assert.IsType<ObjectResult>(failed.Result).StatusCode);
Assert.DoesNotContain("private", Assert.IsType<NewsletterSignupResult>(
Assert.IsType<ObjectResult>(failed.Result).Value).Error!);
}

private static NewsletterController Controller(Provider handler,
string endpoint = "http://trusted-internal-service/subscribe", string list = "configured-list")
{
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{ ["SGFDevs:NewsletterEndpoint"] = endpoint, ["SGFDevs:NewsletterListId"] = list }).Build();
return new NewsletterController(new NewsletterHelper(config, new Factory(handler)));
}

private sealed class Factory(Provider handler) : IHttpClientFactory
{
public HttpClient CreateClient(string name)
{
Assert.Equal(NewsletterHelper.ClientName, name);
return new HttpClient(handler, false);
}
}

private sealed class Provider(HttpStatusCode status, string body) : HttpMessageHandler
{
public int Calls;
public bool Throw;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token)
{
Calls++;
Assert.Equal(HttpMethod.Post, request.Method);
Assert.Empty(request.Headers);
Assert.Equal("{\"email\":\"person@example.test\",\"list_uuids\":[\"configured-list\"]}",
await request.Content!.ReadAsStringAsync(token));
if (Throw) throw new HttpRequestException("private endpoint and credentials");
return new HttpResponseMessage(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
}
}
}
46 changes: 46 additions & 0 deletions SgfDevs/Controllers/NewsletterController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#nullable enable

using System.ComponentModel.DataAnnotations;
using System.Net.Mail;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using SgfDevs.Dev;

namespace SGFDevs.Controllers;

// MemberBridge protects this anonymous server-only endpoint, without a member session.
[ApiController]
[AllowAnonymous]
[DisableCors]
public sealed class NewsletterController(NewsletterHelper newsletter) : ControllerBase
{
[HttpPost("api/v1/member/newsletter", Name = "Newsletter_Signup")]
[RequestSizeLimit(4096)]
[ProducesResponseType<NewsletterSignupResult>(StatusCodes.Status200OK)]
[ProducesResponseType<NewsletterSignupResult>(StatusCodes.Status400BadRequest)]
[ProducesResponseType<NewsletterSignupResult>(StatusCodes.Status503ServiceUnavailable)]
public async Task<ActionResult<NewsletterSignupResult>> Signup(
NewsletterSignupRequest request, CancellationToken cancellationToken = default)
{
// Whitespace is filled too. Never trim the legacy empty-only honeypot.
if (!string.IsNullOrEmpty(request.Name))
return BadRequest(new NewsletterSignupResult(false, "Unable to sign up. Please try again."));

var email = request.Email?.Trim() ?? "";
if (email.Length > 254 || !new EmailAddressAttribute().IsValid(email) ||
!MailAddress.TryCreate(email, out var address) || address.Address != email)
return BadRequest(new NewsletterSignupResult(false, "Please enter a valid email address."));

if (!newsletter.IsAvailable || !await newsletter.Subscribe(email, cancellationToken))
return StatusCode(StatusCodes.Status503ServiceUnavailable,
new NewsletterSignupResult(false, "Newsletter signup is unavailable right now. Please try again."));
return Ok(new NewsletterSignupResult(true));
}
}

public sealed record NewsletterSignupRequest(string? Email, string? Name);
public sealed record NewsletterSignupResult(bool Accepted, string? Error = null);
47 changes: 27 additions & 20 deletions SgfDevs/Dev/NewsletterHelper.cs
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
using System;
#nullable enable

using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;

namespace SgfDevs.Dev;

public class NewsletterHelper
{
public const string ClientName = "newsletter";
private readonly IHttpClientFactory _httpClientFactory;
private readonly string _endpoint;
private readonly string _listId;
private readonly string? _endpoint;
private readonly string? _listId;

public NewsletterHelper(IConfiguration configuration, IHttpClientFactory httpClientFactory)
{
Expand All @@ -21,34 +25,37 @@ public NewsletterHelper(IConfiguration configuration, IHttpClientFactory httpCli
_listId = configuration["SGFDevs:NewsletterListId"];
}

public async Task<bool> Subscribe(string email)
// Trusted configuration may point to an internal HTTP service.
public bool IsAvailable => !string.IsNullOrWhiteSpace(_listId) &&
Uri.TryCreate(_endpoint, UriKind.Absolute, out var uri) &&
uri.Scheme is "http" or "https" && string.IsNullOrEmpty(uri.UserInfo);

public async Task<bool> Subscribe(string email, CancellationToken cancellationToken = default)
{
if (!IsAvailable) return false;
try
{

var request = new HttpRequestMessage
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(6));
using var request = new HttpRequestMessage(HttpMethod.Post, _endpoint)
{
Method = HttpMethod.Post,
RequestUri = new Uri(_endpoint),
Content = JsonContent.Create(new Dictionary<string, object>
{
{ "email", email },
{ "list_uuids", new [] { _listId } },
{ "list_uuids", new[] { _listId! } }
})
};

var client = _httpClientFactory.CreateClient();

var response = await client.SendAsync(request);
var jsonStream = await response.Content.ReadAsStreamAsync();

var result = await JsonSerializer.DeserializeAsync<SubscribeResponseDto>(jsonStream);

return result.Data;
var client = _httpClientFactory.CreateClient(ClientName);
using var response = await client.SendAsync(request, timeout.Token);
if (!response.IsSuccessStatusCode) return false;
using var jsonStream = await response.Content.ReadAsStreamAsync(timeout.Token);
var result = await JsonSerializer.DeserializeAsync<SubscribeResponseDto>(
jsonStream, new JsonSerializerOptions(JsonSerializerDefaults.Web), timeout.Token);
return result?.Data == true;
}
catch (Exception e)
catch
{
Console.WriteLine(e);
// Provider exceptions can contain configured URLs or submitted addresses.
return false;
}
}
Expand Down
13 changes: 11 additions & 2 deletions SgfDevs/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ serverRole is ServerRole.Unknown ||
builder.Services.AddOpenApi("sgf-member-v1", options =>
{
options.ShouldInclude = description => description.ActionDescriptor is ControllerActionDescriptor action &&
action.AttributeRouteInfo?.Name is "Member_Login" or "Member_Logout" or "Member_Session" or "Member_Register" or "Member_ForgotPassword" or "Member_ResetPassword" or "Member_Profile" or "Member_ProfileUpdate" or "Member_AvatarUpload";
action.AttributeRouteInfo?.Name is "Member_Login" or "Member_Logout" or "Member_Session" or "Member_Register" or "Member_ForgotPassword" or "Member_ResetPassword" or "Member_Profile" or "Member_ProfileUpdate" or "Member_AvatarUpload" or "Newsletter_Signup";
options.AddDocumentTransformer((document, _, _) =>
{
document.Info = new OpenApiInfo { Title = "SGF private member bridge", Version = "1.0" };
Expand All @@ -110,7 +110,8 @@ serverRole is ServerRole.Unknown ||
string.Equals(context.Request.Path.Value?.TrimEnd('/'), "/api/v1/member/register", StringComparison.OrdinalIgnoreCase) ||
string.Equals(context.Request.Path.Value?.TrimEnd('/'), "/api/v1/member/forgot-password", StringComparison.OrdinalIgnoreCase) ||
string.Equals(context.Request.Path.Value?.TrimEnd('/'), "/api/v1/member/reset-password", StringComparison.OrdinalIgnoreCase) ||
string.Equals(context.Request.Path.Value?.TrimEnd('/'), "/api/v1/member/avatar", StringComparison.OrdinalIgnoreCase)) && HttpMethods.IsPost(context.Request.Method)
string.Equals(context.Request.Path.Value?.TrimEnd('/'), "/api/v1/member/avatar", StringComparison.OrdinalIgnoreCase) ||
string.Equals(context.Request.Path.Value?.TrimEnd('/'), "/api/v1/member/newsletter", StringComparison.OrdinalIgnoreCase)) && HttpMethods.IsPost(context.Request.Method)
? RateLimitPartition.GetFixedWindowLimiter(context.Request.Path.Value!.TrimEnd('/').ToLowerInvariant(), _ => new FixedWindowRateLimiterOptions
{
PermitLimit = context.Request.Path.Value!.TrimEnd('/').Equals("/api/v1/member/avatar", StringComparison.OrdinalIgnoreCase) ? 5 : 20, Window = TimeSpan.FromMinutes(1), QueueLimit = 0, AutoReplenishment = true
Expand All @@ -121,6 +122,14 @@ serverRole is ServerRole.Unknown ||
builder.Services.AddHealthChecks()
.AddCheck<ReadinessHealthCheck>("ready", tags: ["ready"]);
builder.Services.AddHttpClient();
builder.Services.AddHttpClient(NewsletterHelper.ClientName, client =>
{
client.Timeout = TimeSpan.FromSeconds(6);
client.MaxResponseContentBufferSize = 16 * 1024;
}).ConfigurePrimaryHttpMessageHandler(() => new System.Net.Http.HttpClientHandler
{
AllowAutoRedirect = false, UseCookies = false
}).RemoveAllLoggers();
builder.Services.Configure<EventSyncOptions>(builder.Configuration.GetSection("SGFDevs"));
builder.Services.Configure<SiteFeaturesOptions>(builder.Configuration.GetSection("SGFDevs:Site"));
builder.Services.AddScoped<MemberConverter>();
Expand Down
6 changes: 6 additions & 0 deletions member-bridge.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
# Newsletter signup

Private POST `/api/v1/member/newsletter` uses the existing bridge secret and rejects browser Origin requests. It does not require a member session. Requests have a 4 KiB ceiling and a separate aggregate limit of 20 per minute, without a queue. The legacy `name` honeypot must be null or empty; whitespace or any other value rejects the request before the provider is contacted.

Set `SGFDevs:NewsletterEndpoint` and `SGFDevs:NewsletterListId` through existing private configuration. Missing values return unavailable. Trusted internal HTTP services remain supported. Local-bootstrap configuration must still leave both empty. The existing NewsletterHelper sends only email and the configured list ID, with no member/bridge/browser credentials, no redirects, a six-second timeout and a 16 KiB response ceiling. Only a successful HTTP response with `data: true` is accepted. False, malformed and failed responses return a generic retry error. Acceptance means confirmation is pending, not that delivery or subscription is proven.

# Current-member profile editing

Private GET and POST `/api/v1/member/profile` authenticate only the existing Identity.Application member cookie. The edit DTO rejects unknown fields. It never accepts member IDs, usernames, roles, member display tags or avatar inputs. Omitted fields stay unchanged.
Expand Down
Loading