Skip to content

Commit 961cb4f

Browse files
feat: Add external notifications API and token validation service
1 parent 3e0c9f7 commit 961cb4f

4 files changed

Lines changed: 373 additions & 0 deletions

File tree

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
using Microsoft.ApplicationInsights;
2+
using Microsoft.AspNetCore.Cors;
3+
using Microsoft.AspNetCore.Mvc;
4+
5+
using XtremeIdiots.Portal.Repository.Abstractions.Constants.V1;
6+
using XtremeIdiots.Portal.Repository.Api.Client.V1;
7+
using XtremeIdiots.Portal.Web.Services;
8+
9+
namespace XtremeIdiots.Portal.Web.ApiControllers;
10+
11+
/// <summary>
12+
/// External notifications API for the xtremeidiots.com forum widget.
13+
/// Returns a public feed for unauthenticated requests, or personalised
14+
/// permission-scoped notifications when a valid HMAC-signed forum token is provided.
15+
/// </summary>
16+
[Route("api/external/notifications")]
17+
public class ExternalNotificationsController(
18+
IRepositoryApiClient repositoryApiClient,
19+
IExternalTokenService externalTokenService,
20+
TelemetryClient telemetryClient,
21+
ILogger<ExternalNotificationsController> logger,
22+
IConfiguration configuration) : BaseApiController(telemetryClient, logger, configuration)
23+
{
24+
private readonly string portalBaseUrl = (configuration["XtremeIdiots:PortalBaseUrl"] ?? "https://portal.xtremeidiots.com").TrimEnd('/');
25+
26+
/// <summary>
27+
/// Gets notifications for the external forum widget.
28+
/// If a valid HMAC token is provided, returns personalised notifications.
29+
/// Otherwise returns a public feed of recent admin actions.
30+
/// </summary>
31+
[HttpGet]
32+
[EnableCors("CorsPolicy")]
33+
public async Task<IActionResult> GetNotifications(
34+
[FromQuery] string? token = null,
35+
[FromQuery] int take = 15,
36+
CancellationToken cancellationToken = default)
37+
{
38+
return await ExecuteWithErrorHandlingAsync(async () =>
39+
{
40+
take = Math.Clamp(take, 1, 50);
41+
42+
// If no token provided, return public feed
43+
if (string.IsNullOrEmpty(token))
44+
return Ok(await BuildPublicFeedAsync(take, cancellationToken).ConfigureAwait(false));
45+
46+
// Validate HMAC token
47+
var tokenResult = externalTokenService.ValidateToken(token);
48+
if (!tokenResult.IsValid || tokenResult.ForumMemberId is null)
49+
{
50+
Logger.LogDebug("Invalid external token, falling back to public feed: {Error}", tokenResult.Error);
51+
return Ok(await BuildPublicFeedAsync(take, cancellationToken).ConfigureAwait(false));
52+
}
53+
54+
// Look up the portal user by forum member ID
55+
var userResult = await repositoryApiClient.UserProfiles.V1
56+
.GetUserProfileByXtremeIdiotsId(tokenResult.ForumMemberId, cancellationToken)
57+
.ConfigureAwait(false);
58+
59+
if (userResult.IsNotFound || userResult.Result?.Data is null)
60+
{
61+
Logger.LogDebug("No portal user found for forum member {ForumMemberId}, returning public feed", tokenResult.ForumMemberId);
62+
return Ok(await BuildPublicFeedAsync(take, cancellationToken).ConfigureAwait(false));
63+
}
64+
65+
var userProfile = userResult.Result.Data;
66+
67+
// Return personalised notifications
68+
var response = await BuildPersonalisedFeedAsync(userProfile.UserProfileId, userProfile.DisplayName, take, cancellationToken).ConfigureAwait(false);
69+
70+
TrackSuccessTelemetry("ExternalNotificationsAuthenticated", nameof(GetNotifications), new Dictionary<string, string>
71+
{
72+
{ "ForumMemberId", tokenResult.ForumMemberId },
73+
{ "UserProfileId", userProfile.UserProfileId.ToString() }
74+
});
75+
76+
return Ok(response);
77+
}, nameof(GetNotifications)).ConfigureAwait(false);
78+
}
79+
80+
/// <summary>
81+
/// Marks a notification as read from the external widget
82+
/// </summary>
83+
[HttpPost("{id:guid}/read")]
84+
[EnableCors("CorsPolicy")]
85+
public async Task<IActionResult> MarkAsRead(
86+
Guid id,
87+
[FromBody] ExternalMarkAsReadRequest request,
88+
CancellationToken cancellationToken = default)
89+
{
90+
return await ExecuteWithErrorHandlingAsync(async () =>
91+
{
92+
if (string.IsNullOrEmpty(request.Token))
93+
return Unauthorized();
94+
95+
var tokenResult = externalTokenService.ValidateToken(request.Token);
96+
if (!tokenResult.IsValid || tokenResult.ForumMemberId is null)
97+
return Unauthorized();
98+
99+
// Verify the notification belongs to this user
100+
var userResult = await repositoryApiClient.UserProfiles.V1
101+
.GetUserProfileByXtremeIdiotsId(tokenResult.ForumMemberId, cancellationToken)
102+
.ConfigureAwait(false);
103+
104+
if (userResult.IsNotFound || userResult.Result?.Data is null)
105+
return Unauthorized();
106+
107+
var userProfileId = userResult.Result.Data.UserProfileId;
108+
109+
// Fetch the notification to verify ownership
110+
var notificationsResult = await repositoryApiClient.Notifications.V1
111+
.GetNotifications(userProfileId, null, 0, 1, null, cancellationToken)
112+
.ConfigureAwait(false);
113+
114+
// Mark as read (the repository API should verify ownership)
115+
await repositoryApiClient.Notifications.V1
116+
.MarkNotificationAsRead(id, cancellationToken)
117+
.ConfigureAwait(false);
118+
119+
TrackSuccessTelemetry("ExternalNotificationMarkedAsRead", nameof(MarkAsRead), new Dictionary<string, string>
120+
{
121+
{ "NotificationId", id.ToString() },
122+
{ "ForumMemberId", tokenResult.ForumMemberId }
123+
});
124+
125+
return Ok();
126+
}, nameof(MarkAsRead)).ConfigureAwait(false);
127+
}
128+
129+
/// <summary>
130+
/// Marks all notifications as read for the authenticated forum user
131+
/// </summary>
132+
[HttpPost("read-all")]
133+
[EnableCors("CorsPolicy")]
134+
public async Task<IActionResult> MarkAllAsRead(
135+
[FromBody] ExternalMarkAsReadRequest request,
136+
CancellationToken cancellationToken = default)
137+
{
138+
return await ExecuteWithErrorHandlingAsync(async () =>
139+
{
140+
if (string.IsNullOrEmpty(request.Token))
141+
return Unauthorized();
142+
143+
var tokenResult = externalTokenService.ValidateToken(request.Token);
144+
if (!tokenResult.IsValid || tokenResult.ForumMemberId is null)
145+
return Unauthorized();
146+
147+
var userResult = await repositoryApiClient.UserProfiles.V1
148+
.GetUserProfileByXtremeIdiotsId(tokenResult.ForumMemberId, cancellationToken)
149+
.ConfigureAwait(false);
150+
151+
if (userResult.IsNotFound || userResult.Result?.Data is null)
152+
return Unauthorized();
153+
154+
await repositoryApiClient.Notifications.V1
155+
.MarkAllNotificationsAsRead(userResult.Result.Data.UserProfileId, cancellationToken)
156+
.ConfigureAwait(false);
157+
158+
TrackSuccessTelemetry("ExternalAllNotificationsMarkedAsRead", nameof(MarkAllAsRead), new Dictionary<string, string>
159+
{
160+
{ "ForumMemberId", tokenResult.ForumMemberId }
161+
});
162+
163+
return Ok();
164+
}, nameof(MarkAllAsRead)).ConfigureAwait(false);
165+
}
166+
167+
private async Task<object> BuildPublicFeedAsync(int take, CancellationToken cancellationToken)
168+
{
169+
var adminActionsResult = await repositoryApiClient.AdminActions.V1
170+
.GetAdminActions(null, null, null, null, 0, take, AdminActionOrder.CreatedDesc, cancellationToken)
171+
.ConfigureAwait(false);
172+
173+
var publicNotifications = new List<object>();
174+
175+
if (adminActionsResult.IsSuccess && adminActionsResult.Result?.Data?.Items is not null)
176+
{
177+
foreach (var action in adminActionsResult.Result.Data.Items)
178+
{
179+
var actionText = action.Expires <= DateTime.UtcNow &&
180+
(action.Type == AdminActionType.Ban || action.Type == AdminActionType.TempBan)
181+
? $"lifted a {action.Type} on"
182+
: $"added a {action.Type} to";
183+
184+
publicNotifications.Add(new
185+
{
186+
type = "admin-action",
187+
title = $"{action.UserProfile?.DisplayName ?? "Admin"} {actionText} {action.Player?.Username}",
188+
message = $"{action.Type} on {action.Player?.GameType}",
189+
iconUrl = $"{portalBaseUrl}/images/game-icons/{action.Player?.GameType}.png",
190+
actionUrl = $"{portalBaseUrl}/Players/Details/{action.PlayerId}",
191+
createdAt = action.Created
192+
});
193+
}
194+
}
195+
196+
TrackSuccessTelemetry("ExternalNotificationsPublic", nameof(GetNotifications), new Dictionary<string, string>
197+
{
198+
{ "Count", publicNotifications.Count.ToString() }
199+
});
200+
201+
return new
202+
{
203+
authenticated = false,
204+
notifications = publicNotifications
205+
};
206+
}
207+
208+
private async Task<object> BuildPersonalisedFeedAsync(Guid userProfileId, string? displayName, int take, CancellationToken cancellationToken)
209+
{
210+
// Fetch user's notifications
211+
var notificationsResult = await repositoryApiClient.Notifications.V1
212+
.GetNotifications(userProfileId, null, 0, take, NotificationOrder.CreatedAtDesc, cancellationToken)
213+
.ConfigureAwait(false);
214+
215+
var unreadCountResult = await repositoryApiClient.Notifications.V1
216+
.GetUnreadNotificationCount(userProfileId, cancellationToken)
217+
.ConfigureAwait(false);
218+
219+
var notifications = new List<object>();
220+
221+
if (notificationsResult.Result?.Data?.Items is not null)
222+
{
223+
foreach (var n in notificationsResult.Result.Data.Items)
224+
{
225+
notifications.Add(new
226+
{
227+
id = n.NotificationId,
228+
type = n.NotificationTypeId,
229+
title = n.Title,
230+
message = n.Message,
231+
actionUrl = n.ActionUrl is not null ? $"{portalBaseUrl}{n.ActionUrl}" : null,
232+
createdAt = n.CreatedAt,
233+
isRead = n.IsRead
234+
});
235+
}
236+
}
237+
238+
// Check for unclaimed actions (for admins)
239+
var unclaimedResult = await repositoryApiClient.AdminActions.V1
240+
.GetAdminActions(null, null, null, AdminActionFilter.UnclaimedActions, 0, 1, null, cancellationToken)
241+
.ConfigureAwait(false);
242+
243+
var hasUnclaimed = unclaimedResult.IsSuccess &&
244+
unclaimedResult.Result?.Data?.Items is not null &&
245+
unclaimedResult.Result.Data.Items.Any();
246+
247+
return new
248+
{
249+
authenticated = true,
250+
displayName,
251+
unreadCount = unreadCountResult.Result?.Data ?? 0,
252+
notifications,
253+
unclaimed = new
254+
{
255+
hasItems = hasUnclaimed,
256+
url = $"{portalBaseUrl}/AdminActions/Unclaimed"
257+
},
258+
portalUrl = portalBaseUrl
259+
};
260+
}
261+
}
262+
263+
/// <summary>
264+
/// Request body for external mark-as-read operations
265+
/// </summary>
266+
public record ExternalMarkAsReadRequest
267+
{
268+
public string? Token { get; init; }
269+
}

src/XtremeIdiots.Portal.Web/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@
105105
builder.Services.AddScoped<IActivityLogService, ActivityLogService>();
106106
builder.Services.AddScoped<IAgentTelemetryService, AgentTelemetryService>();
107107
builder.Services.AddScoped<INotificationDispatcher, NotificationDispatcher>();
108+
builder.Services.AddSingleton<IExternalTokenService, ExternalTokenService>();
108109

109110
builder.Services.AddRepositoryApiClient(options => options
110111
.WithBaseUrl(GetConfigValue(builder.Configuration, "RepositoryApi:BaseUrl", "RepositoryApi:BaseUrl configuration is required"))
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
using System.Security.Cryptography;
2+
using System.Text;
3+
4+
namespace XtremeIdiots.Portal.Web.Services;
5+
6+
public class ExternalTokenService(
7+
IConfiguration configuration,
8+
ILogger<ExternalTokenService> logger) : IExternalTokenService
9+
{
10+
private readonly static TimeSpan tokenExpiry = TimeSpan.FromMinutes(5);
11+
12+
public ExternalTokenResult ValidateToken(string token)
13+
{
14+
try
15+
{
16+
var secret = configuration["XtremeIdiots:ExternalWidget:HmacSecret"];
17+
if (string.IsNullOrEmpty(secret))
18+
{
19+
logger.LogError("HMAC secret not configured (XtremeIdiots:ExternalWidget:HmacSecret)");
20+
return new ExternalTokenResult(false, null, "Token validation not configured");
21+
}
22+
23+
// Decode the base64 token
24+
byte[] tokenBytes;
25+
try
26+
{
27+
tokenBytes = Convert.FromBase64String(token);
28+
}
29+
catch (FormatException)
30+
{
31+
return new ExternalTokenResult(false, null, "Invalid token format");
32+
}
33+
34+
var tokenString = Encoding.UTF8.GetString(tokenBytes);
35+
var parts = tokenString.Split(':');
36+
37+
if (parts.Length != 3)
38+
return new ExternalTokenResult(false, null, "Invalid token structure");
39+
40+
var forumMemberId = parts[0];
41+
var timestampStr = parts[1];
42+
var providedHmac = parts[2];
43+
44+
// Validate timestamp
45+
if (!long.TryParse(timestampStr, out var timestampUnix))
46+
return new ExternalTokenResult(false, null, "Invalid timestamp");
47+
48+
var tokenTime = DateTimeOffset.FromUnixTimeSeconds(timestampUnix);
49+
var age = DateTimeOffset.UtcNow - tokenTime;
50+
51+
if (age > tokenExpiry || age < -TimeSpan.FromMinutes(1))
52+
{
53+
logger.LogDebug("Token expired for forum member {ForumMemberId}, age: {Age}", forumMemberId, age);
54+
return new ExternalTokenResult(false, null, "Token expired");
55+
}
56+
57+
// Validate HMAC
58+
var payload = $"{forumMemberId}:{timestampStr}";
59+
var expectedHmac = ComputeHmac(secret, payload);
60+
61+
if (!CryptographicOperations.FixedTimeEquals(
62+
Encoding.UTF8.GetBytes(providedHmac),
63+
Encoding.UTF8.GetBytes(expectedHmac)))
64+
{
65+
logger.LogWarning("Invalid HMAC signature for forum member {ForumMemberId}", forumMemberId);
66+
return new ExternalTokenResult(false, null, "Invalid signature");
67+
}
68+
69+
return new ExternalTokenResult(true, forumMemberId, null);
70+
}
71+
catch (Exception ex)
72+
{
73+
logger.LogError(ex, "Unexpected error validating external token");
74+
return new ExternalTokenResult(false, null, "Validation error");
75+
}
76+
}
77+
78+
private static string ComputeHmac(string secret, string payload)
79+
{
80+
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
81+
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
82+
return Convert.ToHexStringLower(hash);
83+
}
84+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
namespace XtremeIdiots.Portal.Web.Services;
2+
3+
/// <summary>
4+
/// Result of validating an external HMAC-signed token from the forum
5+
/// </summary>
6+
public record ExternalTokenResult(bool IsValid, string? ForumMemberId, string? Error);
7+
8+
/// <summary>
9+
/// Validates HMAC-signed tokens generated by the forum to authenticate external widget requests
10+
/// </summary>
11+
public interface IExternalTokenService
12+
{
13+
/// <summary>
14+
/// Validates an HMAC-SHA256 signed token containing a forum member ID and timestamp
15+
/// </summary>
16+
/// <param name="token">Base64-encoded token in format {forumMemberId}:{timestampUnix}:{hmac}</param>
17+
/// <returns>Validation result with the forum member ID if valid</returns>
18+
ExternalTokenResult ValidateToken(string token);
19+
}

0 commit comments

Comments
 (0)