b53c7fb736
Features: - Category viewer ranges + subcategory templates (admin group modal, tree workspace) - Nomination enrichment via TwitchTracker API (NominationEnrichmentService, TwitchTrackerViewerStatsProvider) with admin tracking rules editor - Nomination group tracker: CategoryGroupName as primary identifier, CategoryId stays as nullable legacy field; StreamerIdentity table - Dynamic showact application form builder (AdminShowactFormBuilder, ShowactApplicationSchedule) - Session idle timeout setting (AdminSessionTimeoutCard) - Share URLs for X and Discord (SiteSettings, public extras) - Workflow rules now stored per season (falls back to global SiteSettings) - New admin routes: settings/access, settings/workflows, tracking-rules - New admin review workspace with subcategory tabs - AdminCategoriesView rebuilt with group/subcategory modals Migrations (all additive): - AddShareUrls, AddShowactDynamicForm, AddCategoryViewerRanges, AddSessionIdleTimeoutSettings, AddSeasonSubcategoryTemplates, AddNominationGroupTrackerIdentity, AddShowactApplicationSchedule, AddSeasonWorkflowRulesJson Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
131 lines
4.8 KiB
C#
131 lines
4.8 KiB
C#
using Backend.Common;
|
|
using Backend.Contracts;
|
|
using Backend.Data;
|
|
using Backend.Domain;
|
|
using Backend.Repositories;
|
|
using Backend.Security;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using System.Security.Cryptography;
|
|
|
|
namespace Backend.Services;
|
|
|
|
public sealed class UserSessionService(IUserSessionRepository userSessionRepository, AwardsDbContext db) : IUserSessionService
|
|
{
|
|
public const int MinimumIdleTimeoutHours = 3;
|
|
public const int DefaultIdleTimeoutHours = 3;
|
|
private static readonly TimeSpan AbsoluteSessionLifetime = TimeSpan.FromDays(30);
|
|
|
|
public async Task<UserSession?> ResolveSessionAsync(HttpContext context, CancellationToken cancellationToken = default)
|
|
{
|
|
var token = ReadBearerToken(context);
|
|
if (string.IsNullOrWhiteSpace(token))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var session = await userSessionRepository.GetActiveByTokenAsync(token, cancellationToken);
|
|
if (session is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var now = DateTimeOffset.UtcNow;
|
|
var idleSessionLifetime = await ResolveIdleSessionLifetimeAsync(cancellationToken);
|
|
if (IsExpired(session, now, idleSessionLifetime))
|
|
{
|
|
session.IsActive = false;
|
|
await userSessionRepository.SaveChangesAsync(cancellationToken);
|
|
return null;
|
|
}
|
|
|
|
session.LastSeenAt = now;
|
|
await userSessionRepository.SaveChangesAsync(cancellationToken);
|
|
context.SetCurrentSession(session);
|
|
return session;
|
|
}
|
|
|
|
public async Task<int> GetIdleTimeoutHoursAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
var configuredHours = await db.SiteSettings
|
|
.AsNoTracking()
|
|
.Where(item => item.Id == 1)
|
|
.Select(item => (int?)item.SessionIdleTimeoutHours)
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
|
|
return NormalizeIdleTimeoutHours(configuredHours);
|
|
}
|
|
|
|
public Task<UserSession> CreateDevSessionAsync(LoginRequest request, RequestMetadata metadata, CancellationToken cancellationToken = default)
|
|
{
|
|
return CreateSessionAsync(
|
|
request.TwitchUserId,
|
|
request.DisplayName,
|
|
request.Role,
|
|
metadata,
|
|
cancellationToken);
|
|
}
|
|
|
|
public Task<UserSession> CreateSessionAsync(
|
|
string twitchUserId,
|
|
string displayName,
|
|
string role,
|
|
RequestMetadata metadata,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var normalizedTwitchUserId = twitchUserId.Trim();
|
|
var normalizedDisplayName = displayName.Trim();
|
|
var session = new UserSession
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
SessionToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(),
|
|
TwitchUserId = normalizedTwitchUserId,
|
|
DisplayName = normalizedDisplayName,
|
|
Role = AdminRoles.Normalize(role),
|
|
CreatedFromIp = metadata.ClientIp,
|
|
UserAgent = metadata.UserAgent,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
LastSeenAt = DateTimeOffset.UtcNow,
|
|
IsActive = true,
|
|
};
|
|
|
|
userSessionRepository.Add(session);
|
|
return PersistAndReturnAsync(session, cancellationToken);
|
|
}
|
|
|
|
public Task<int> CountRecentSessionsFromIpAsync(string ipAddress, DateTimeOffset since, CancellationToken cancellationToken = default) =>
|
|
userSessionRepository.CountRecentSessionsFromIpAsync(ipAddress, since, cancellationToken);
|
|
|
|
public async Task LogoutAsync(UserSession session, CancellationToken cancellationToken = default)
|
|
{
|
|
session.IsActive = false;
|
|
await userSessionRepository.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
private static string? ReadBearerToken(HttpContext context)
|
|
{
|
|
var header = context.Request.Headers.Authorization.ToString();
|
|
return header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
|
|
? header["Bearer ".Length..].Trim()
|
|
: null;
|
|
}
|
|
|
|
private async Task<TimeSpan> ResolveIdleSessionLifetimeAsync(CancellationToken cancellationToken)
|
|
{
|
|
var idleTimeoutHours = await GetIdleTimeoutHoursAsync(cancellationToken);
|
|
return TimeSpan.FromHours(idleTimeoutHours);
|
|
}
|
|
|
|
public static int NormalizeIdleTimeoutHours(int? configuredHours) =>
|
|
Math.Max(MinimumIdleTimeoutHours, configuredHours ?? DefaultIdleTimeoutHours);
|
|
|
|
private static bool IsExpired(UserSession session, DateTimeOffset now, TimeSpan idleSessionLifetime) =>
|
|
session.CreatedAt <= now.Subtract(AbsoluteSessionLifetime)
|
|
|| session.LastSeenAt <= now.Subtract(idleSessionLifetime);
|
|
|
|
private async Task<UserSession> PersistAndReturnAsync(UserSession session, CancellationToken cancellationToken)
|
|
{
|
|
await userSessionRepository.SaveChangesAsync(cancellationToken);
|
|
return session;
|
|
}
|
|
}
|