From 5a915ebdd1b0e3829c3ed0a7963b9048e10530e7 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Tue, 23 Jun 2026 19:29:23 -0700 Subject: [PATCH] Harden notifications: bump MailKit, bound webhook body, share truncation, skip soft-deleted members --- .../LineNotificationServiceTests.cs | 25 +++++++++++++++++++ .../Controllers/LineWebhookController.cs | 1 + API/ROLAC.API/ROLAC.API.csproj | 2 +- .../Services/Notifications/EmailService.cs | 6 +---- .../Notifications/LineNotificationService.cs | 8 ++++-- .../Notifications/NotificationModels.cs | 10 ++++++++ 6 files changed, 44 insertions(+), 8 deletions(-) diff --git a/API/ROLAC.API.Tests/Services/Notifications/LineNotificationServiceTests.cs b/API/ROLAC.API.Tests/Services/Notifications/LineNotificationServiceTests.cs index 328ac73..108a7bb 100644 --- a/API/ROLAC.API.Tests/Services/Notifications/LineNotificationServiceTests.cs +++ b/API/ROLAC.API.Tests/Services/Notifications/LineNotificationServiceTests.cs @@ -183,4 +183,29 @@ public class LineNotificationServiceTests Assert.Equal(1, result.FailedCount); Assert.Equal(1, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Failed)); } + + [Fact] + public async Task SendLineAsync_SkipsSoftDeletedMembers() + { + using var db = BuildDb(); + var memberId = await SeedMemberAsync(db); + db.MemberChannelBindings.Add(new MemberChannelBinding + { + MemberId = memberId, Channel = "line", ExternalId = "U-DEL", BoundAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + + // Soft-delete the member. + var member = await db.Members.FirstAsync(m => m.Id == memberId); + member.IsDeleted = true; + await db.SaveChangesAsync(); + + var channel = new FakeMessageChannel(); + var service = new LineNotificationService(db, channel); + + var result = await service.SendLineAsync("notice", new[] { memberId }, Array.Empty(), "admin-1"); + + Assert.Equal(0, result.SentCount); + Assert.Empty(channel.UserPushes); + } } diff --git a/API/ROLAC.API/Controllers/LineWebhookController.cs b/API/ROLAC.API/Controllers/LineWebhookController.cs index 42e9a70..5a144a3 100644 --- a/API/ROLAC.API/Controllers/LineWebhookController.cs +++ b/API/ROLAC.API/Controllers/LineWebhookController.cs @@ -33,6 +33,7 @@ public sealed class LineWebhookController : ControllerBase } [HttpPost("webhook")] + [RequestSizeLimit(262_144)] public async Task Webhook(CancellationToken ct) { using var reader = new StreamReader(Request.Body, Encoding.UTF8); diff --git a/API/ROLAC.API/ROLAC.API.csproj b/API/ROLAC.API/ROLAC.API.csproj index 322deb9..39e6945 100644 --- a/API/ROLAC.API/ROLAC.API.csproj +++ b/API/ROLAC.API/ROLAC.API.csproj @@ -12,7 +12,7 @@ Provides DevExpress.Drawing.v24.1.Skia.dll; without it RichEditDocumentServer throws DllNotFoundException at runtime on Linux (Windows falls back to GDI+). --> - + diff --git a/API/ROLAC.API/Services/Notifications/EmailService.cs b/API/ROLAC.API/Services/Notifications/EmailService.cs index 5141715..720d7af 100644 --- a/API/ROLAC.API/Services/Notifications/EmailService.cs +++ b/API/ROLAC.API/Services/Notifications/EmailService.cs @@ -12,8 +12,6 @@ namespace ROLAC.API.Services.Notifications; /// public sealed class EmailService : IEmailService { - private const int BodyLogMaxLength = 8000; - private readonly AppDbContext _db; private readonly ISmtpDispatcher _dispatcher; private readonly CurrentUserAccessor _currentUser; @@ -44,7 +42,7 @@ public sealed class EmailService : IEmailService TargetExternalId = recipient.Address, Subject = message.Subject, MemberId = recipient.MemberId, - Body = Truncate(message.HtmlBody), + Body = NotificationLogText.Truncate(message.HtmlBody), SentByUserId = sentBy, SentAt = DateTime.UtcNow, }; @@ -97,6 +95,4 @@ public sealed class EmailService : IEmailService return resolved; } - private static string Truncate(string body) => - body.Length <= BodyLogMaxLength ? body : body[..BodyLogMaxLength] + "…[truncated]"; } diff --git a/API/ROLAC.API/Services/Notifications/LineNotificationService.cs b/API/ROLAC.API/Services/Notifications/LineNotificationService.cs index fdb83b1..5a2f786 100644 --- a/API/ROLAC.API/Services/Notifications/LineNotificationService.cs +++ b/API/ROLAC.API/Services/Notifications/LineNotificationService.cs @@ -31,8 +31,12 @@ public sealed class LineNotificationService : ILineNotificationService var failures = new List(); var sentCount = 0; + var liveMemberIds = await _db.Members + .Where(m => memberIds.Contains(m.Id)) + .Select(m => m.Id) + .ToListAsync(ct); var bindings = await _db.MemberChannelBindings - .Where(b => b.Channel == Channel && memberIds.Contains(b.MemberId)) + .Where(b => b.Channel == Channel && liveMemberIds.Contains(b.MemberId)) .ToListAsync(ct); foreach (var binding in bindings) { @@ -146,7 +150,7 @@ public sealed class LineNotificationService : ILineNotificationService TargetExternalId = externalId, MemberId = memberId, MessagingGroupId = groupId, - Body = body, + Body = NotificationLogText.Truncate(body), Status = result.Success ? NotificationStatuses.Sent : NotificationStatuses.Failed, Error = result.Error, SentByUserId = sentBy, diff --git a/API/ROLAC.API/Services/Notifications/NotificationModels.cs b/API/ROLAC.API/Services/Notifications/NotificationModels.cs index 3e14f3d..d9c464f 100644 --- a/API/ROLAC.API/Services/Notifications/NotificationModels.cs +++ b/API/ROLAC.API/Services/Notifications/NotificationModels.cs @@ -47,3 +47,13 @@ public sealed record EmailMessage( string HtmlBody, IReadOnlyList? Attachments = null, string? SentByUserId = null); + +/// Helpers for building NotificationLog rows consistently across channels. +public static class NotificationLogText +{ + public const int BodyMaxLength = 8000; + + /// Caps a body string so an oversized message can't bloat the log table. + public static string Truncate(string body) => + body.Length <= BodyMaxLength ? body : body[..BodyMaxLength] + "…[truncated]"; +}