Skip to content
Merged
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
10 changes: 7 additions & 3 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,19 @@ jobs:
- name: Checkout
uses: actions/checkout@v4

# build-mode: none extracts tracked source files only — no build, no
# compilation. This deliberately excludes compiler/source-generator output
# under obj/ (e.g. Microsoft.AspNetCore.OpenApi's OpenApiXmlCommentSupport
# generated at obj/Debug/net10.0/generated/...), which otherwise floods the
# results with alerts in code nobody edits. `paths-ignore` in the CodeQL
# config does NOT apply to compiled languages, so the exclusion happens here.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: none
config-file: ./.github/codeql/codeql-config.yml

- name: Autobuild
uses: github/codeql-action/autobuild@v3

- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@v3
with:
Expand Down
49 changes: 21 additions & 28 deletions backend/src/BilliardSystem.API/Auth/AdminAuthHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,38 +46,31 @@ protected override async Task<AuthenticateResult> HandleAuthenticateAsync()

var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));

var scope = Context.RequestServices.CreateScope();
try
{
var dbContext = scope.ServiceProvider.GetRequiredService<BilliardDbContext>();

var session = await dbContext.Sessions
.FirstOrDefaultAsync(s => s.TokenHash == tokenHash);
using var scope = Context.RequestServices.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BilliardDbContext>();

if (session is null || !session.IsValid())
{
return AuthenticateResult.Fail("Sesión inválida o expirada.");
}
var session = await dbContext.Sessions
.FirstOrDefaultAsync(s => s.TokenHash == tokenHash);

if (DateTimeOffset.UtcNow - session.LastUsedAt > TimeSpan.FromHours(RefreshThresholdHours))
{
session.Touch();
await dbContext.SaveChangesAsync();
}

var claims = new[]
{
new Claim(ClaimTypes.Name, "Admin"),
new Claim("session_id", session.Id.ToString())
};
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return AuthenticateResult.Success(ticket);
if (session is null || !session.IsValid())
{
return AuthenticateResult.Fail("Sesión inválida o expirada.");
}
finally

if (DateTimeOffset.UtcNow - session.LastUsedAt > TimeSpan.FromHours(RefreshThresholdHours))
{
(scope as IDisposable)?.Dispose();
session.Touch();
await dbContext.SaveChangesAsync();
}

var claims = new[]
{
new Claim(ClaimTypes.Name, "Admin"),
new Claim("session_id", session.Id.ToString())
};
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
}
18 changes: 13 additions & 5 deletions backend/src/BilliardSystem.API/Endpoints/BilliardEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -318,9 +318,11 @@ public static IEndpointRouteBuilder MapBilliardEndpoints(this IEndpointRouteBuil
.FirstOrDefaultAsync(r => r.Id == id && !r.IsResolved, ct);
if (request is null) return Results.NotFound();

// Persist the hash of the code being shown, otherwise /auth/reset can
// never match it (the hash from request creation belongs to a code that
// was never disclosed). Resolution happens on successful reset only.
var code = GenerateRecoveryCode();
var codeHash = HashToken(code);
request.Resolve();
request.ReplaceCode(HashToken(code));
await dbContext.SaveChangesAsync(ct);

return Results.Ok(new { code, userName = request.User?.UserName });
Expand Down Expand Up @@ -612,9 +614,8 @@ await WriteAuditAsync(dbContext, AuditActionType.SettingsChanged, user.GetUserId
{
var tenantId = user.GetTenantId();
if (tenantId is null) return Results.Forbid();
foreach (var pair in values)
foreach (var pair in values.Where(pair => AllowedSettingKeys.Contains(pair.Key)))
{
if (!AllowedSettingKeys.Contains(pair.Key)) continue;
var setting = await dbContext.Settings.FirstOrDefaultAsync(s => s.TenantId == tenantId && s.Key == pair.Key, ct);
if (setting is null) dbContext.Settings.Add(new AppSetting(pair.Key, pair.Value, tenantId));
else setting.Update(pair.Value);
Expand Down Expand Up @@ -1121,7 +1122,14 @@ private static async Task MarkIdempotentAsync(BilliardDbContext dbContext, Guid?
if (transactionId is null) return;
if (await dbContext.IdempotencyKeys.AnyAsync(k => k.TransactionId == transactionId, ct)) return;
dbContext.IdempotencyKeys.Add(new IdempotencyKey(transactionId.Value));
try { await dbContext.SaveChangesAsync(ct); } catch { /* duplicate */ }
try
{
await dbContext.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
// Duplicate key: a concurrent request already recorded this transaction.
}
}

private static async Task WriteAuditAsync(BilliardDbContext dbContext, AuditActionType actionType,
Expand Down
9 changes: 9 additions & 0 deletions backend/src/BilliardSystem.Domain/Entities/RecoveryRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ public RecoveryRequest(Guid tenantId, Guid userId, string codeHash, DateTimeOffs

public bool IsExpired() => DateTimeOffset.UtcNow >= ExpiresAt;

/// <summary>
/// Replaces the stored code hash with the hash of a newly revealed code,
/// so the last revealed code is the one <c>/auth/reset</c> will match.
/// </summary>
public void ReplaceCode(string codeHash)
{
CodeHash = codeHash;
}

public void Resolve()
{
ResolvedAt = DateTimeOffset.UtcNow;
Expand Down
7 changes: 2 additions & 5 deletions backend/src/BilliardSystem.Domain/Entities/Tenant.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,9 @@ private static string GenerateSlug(string name)
.Replace('_', '-');

var result = new StringBuilder(slug.Length);
foreach (var c in slug)
foreach (var c in slug.Where(c => char.IsLetterOrDigit(c) || c == '-'))
{
if (char.IsLetterOrDigit(c) || c == '-')
{
result.Append(c);
}
result.Append(c);
}

var finalSlug = result.ToString().Trim('-');
Expand Down
1 change: 1 addition & 0 deletions backend/tests/BilliardSystem.Tests/MatchHistoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ public void TryCloseFinalRound_AfterCloseRoundUsesLastEndAsStart()
var t1 = t0.AddSeconds(20);
var t2 = t0.AddSeconds(50);
var r1 = match.CloseRound(t1);
r1.Should().NotBeNull();
match.AddScore("yellow", 3, null);
var final = match.TryCloseFinalRound(t2);
final.Should().NotBeNull();
Expand Down
6 changes: 1 addition & 5 deletions frontend/src/app/features/player/player.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,11 +437,7 @@ export class PlayerComponent implements OnInit, OnDestroy {
// monotonic guard: don't let a stale poll (old StartedAt) overwrite a just-started session (00:00 -> old time bug in FreeMode)
if (currentStart === null || serverStart > currentStart || m.roundNumber > this.roundNumber()) {
this.startedAt.set(serverStart);
} else if (
serverStart !== currentStart &&
currentStart !== null &&
Math.abs(serverStart - currentStart) < 5000
) {
} else if (serverStart !== currentStart && Math.abs(serverStart - currentStart) < 5000) {
// small clock skew (server vs client Date.now) — sync to server
this.startedAt.set(serverStart);
}
Expand Down
Loading