C# formatting
This commit is contained in:
@@ -3,7 +3,6 @@ using GameList.Domain;
|
||||
using GameList.Contracts;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Collections.Generic;
|
||||
using GameList.Infrastructure;
|
||||
|
||||
namespace GameList.Endpoints;
|
||||
@@ -12,11 +11,9 @@ public static class AdminEndpoints
|
||||
{
|
||||
public static void MapAdminEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var admin = app.MapGroup("/api/admin")
|
||||
.RequireAuthorization()
|
||||
.AddEndpointFilter<AdminOnlyFilter>();
|
||||
var admin = app.MapGroup("/api/admin").RequireAuthorization().AddEndpointFilter<AdminOnlyFilter>();
|
||||
|
||||
admin.MapPost("/results", async ([FromBody] Contracts.ResultsOpenRequest request, HttpContext ctx, AppDbContext db) =>
|
||||
admin.MapPost("/results", async ([FromBody] ResultsOpenRequest request, HttpContext _, AppDbContext db) =>
|
||||
{
|
||||
var state = await db.AppState.FirstAsync();
|
||||
state.ResultsOpen = request.ResultsOpen;
|
||||
@@ -28,40 +25,37 @@ public static class AdminEndpoints
|
||||
}
|
||||
else
|
||||
{
|
||||
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Vote)
|
||||
.SetProperty(x => x.VotesFinal, false));
|
||||
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Vote).SetProperty(x => x.VotesFinal, false));
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
var currentState = await db.AppState.AsNoTracking().FirstAsync();
|
||||
return Results.Ok(new { currentState.ResultsOpen, currentState.UpdatedAt });
|
||||
return Results.Ok(new
|
||||
{
|
||||
currentState.ResultsOpen,
|
||||
currentState.UpdatedAt
|
||||
});
|
||||
});
|
||||
|
||||
admin.MapGet("/vote-status", async (HttpContext ctx, AppDbContext db) =>
|
||||
admin.MapGet("/vote-status", async (HttpContext _, AppDbContext db) =>
|
||||
{
|
||||
var voters = await db.Players
|
||||
.AsNoTracking()
|
||||
.Include(p => p.Suggestions)
|
||||
.OrderBy(p => p.DisplayName ?? p.Username)
|
||||
.Select(p => new VoteStatusDto(p.Id,
|
||||
p.DisplayName ?? p.Username,
|
||||
p.Username,
|
||||
p.CurrentPhase,
|
||||
p.VotesFinal,
|
||||
p.HasJoker,
|
||||
p.Suggestions.Count,
|
||||
p.Suggestions.Select(s => s.Name).ToList()))
|
||||
.ToListAsync();
|
||||
var voters = await db.Players.AsNoTracking().Include(p => p.Suggestions).OrderBy(p => p.DisplayName ?? p.Username).Select(p => new VoteStatusDto(p.Id, p.DisplayName ?? p.Username, p.Username, p.CurrentPhase, p.VotesFinal, p.HasJoker, p.Suggestions.Count, p.Suggestions.Select(s => s.Name).ToList())).ToListAsync();
|
||||
|
||||
var waiting = voters.Where(v => !v.Finalized).Select(v => v.Name).ToList();
|
||||
var ready = waiting.Count == 0;
|
||||
return Results.Ok(new { voters, ready, waiting });
|
||||
return Results.Ok(new
|
||||
{
|
||||
voters,
|
||||
ready,
|
||||
waiting
|
||||
});
|
||||
});
|
||||
|
||||
admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, HttpContext ctx, AppDbContext db) =>
|
||||
admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, HttpContext _, AppDbContext db) =>
|
||||
{
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == request.PlayerId);
|
||||
if (player is null) return Results.NotFound(new { error = "Player not found." });
|
||||
if (player is null)
|
||||
return Results.NotFound(new { error = "Player not found." });
|
||||
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
if (phase != Phase.Vote)
|
||||
@@ -71,15 +65,18 @@ public static class AdminEndpoints
|
||||
player.VotesFinal = false;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { player.Id, player.HasJoker });
|
||||
return Results.Ok(new
|
||||
{
|
||||
player.Id,
|
||||
player.HasJoker
|
||||
});
|
||||
});
|
||||
|
||||
admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, HttpContext ctx, AppDbContext db) =>
|
||||
admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, HttpContext _, AppDbContext db) =>
|
||||
{
|
||||
var player = await db.Players
|
||||
.Include(p => p.Suggestions)
|
||||
.FirstOrDefaultAsync(p => p.Id == playerId);
|
||||
if (player is null) return Results.NotFound(new { error = "Player not found." });
|
||||
var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId);
|
||||
if (player is null)
|
||||
return Results.NotFound(new { error = "Player not found." });
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
|
||||
@@ -91,9 +88,7 @@ public static class AdminEndpoints
|
||||
if (suggestionIds.Count > 0)
|
||||
{
|
||||
// Break links pointing to these suggestions
|
||||
await db.Suggestions
|
||||
.Where(s => s.ParentSuggestionId != null && suggestionIds.Contains(s.ParentSuggestionId.Value))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ParentSuggestionId, (int?)null));
|
||||
await db.Suggestions.Where(s => s.ParentSuggestionId != null && suggestionIds.Contains(s.ParentSuggestionId.Value)).ExecuteUpdateAsync(s => s.SetProperty(x => x.ParentSuggestionId, (int?)null));
|
||||
|
||||
// Remove votes for these suggestions to avoid orphaned rows
|
||||
await db.Votes.Where(v => suggestionIds.Contains(v.SuggestionId)).ExecuteDeleteAsync();
|
||||
@@ -110,7 +105,8 @@ public static class AdminEndpoints
|
||||
admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
if (phase != Phase.Vote)
|
||||
@@ -132,11 +128,12 @@ public static class AdminEndpoints
|
||||
if (sourceRoot == targetRoot)
|
||||
return Results.BadRequest(new { error = "These games are already linked." });
|
||||
|
||||
var affectedRootIds = new HashSet<int> { sourceRoot, targetRoot };
|
||||
var affectedIds = rootIndex
|
||||
.Where(kv => affectedRootIds.Contains(kv.Value))
|
||||
.Select(kv => kv.Key)
|
||||
.ToList();
|
||||
var affectedRootIds = new HashSet<int>
|
||||
{
|
||||
sourceRoot,
|
||||
targetRoot
|
||||
};
|
||||
var affectedIds = rootIndex.Where(kv => affectedRootIds.Contains(kv.Value)).Select(kv => kv.Key).ToList();
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
|
||||
@@ -155,18 +152,13 @@ public static class AdminEndpoints
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var affectedPlayerIds = await db.Votes
|
||||
.Where(v => affectedIds.Contains(v.SuggestionId))
|
||||
.Select(v => v.PlayerId)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
var affectedPlayerIds = await db.Votes.Where(v => affectedIds.Contains(v.SuggestionId)).Select(v => v.PlayerId).Distinct().ToListAsync();
|
||||
|
||||
await db.Votes.Where(v => affectedIds.Contains(v.SuggestionId)).ExecuteDeleteAsync();
|
||||
|
||||
if (affectedPlayerIds.Count > 0)
|
||||
{
|
||||
await db.Players.Where(p => affectedPlayerIds.Contains(p.Id))
|
||||
.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
|
||||
await db.Players.Where(p => affectedPlayerIds.Contains(p.Id)).ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
|
||||
}
|
||||
|
||||
await tx.CommitAsync();
|
||||
@@ -182,7 +174,8 @@ public static class AdminEndpoints
|
||||
admin.MapPost("/unlink-suggestions", async ([FromBody] UnlinkSuggestionsRequest request, HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
if (phase != Phase.Vote)
|
||||
@@ -191,16 +184,21 @@ public static class AdminEndpoints
|
||||
var suggestions = await db.Suggestions.ToListAsync();
|
||||
var target = suggestions.FirstOrDefault(s => s.Id == request.SuggestionId);
|
||||
if (target is null)
|
||||
return Results.Ok(new { UnlinkedSuggestionIds = Array.Empty<int>(), UnfinalizedPlayers = 0 });
|
||||
return Results.Ok(new
|
||||
{
|
||||
UnlinkedSuggestionIds = Array.Empty<int>(),
|
||||
UnfinalizedPlayers = 0
|
||||
});
|
||||
|
||||
var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId)));
|
||||
if (!rootIndex.TryGetValue(target.Id, out var rootId))
|
||||
return Results.Ok(new { UnlinkedSuggestionIds = Array.Empty<int>(), UnfinalizedPlayers = 0 });
|
||||
return Results.Ok(new
|
||||
{
|
||||
UnlinkedSuggestionIds = Array.Empty<int>(),
|
||||
UnfinalizedPlayers = 0
|
||||
});
|
||||
|
||||
var groupIds = rootIndex
|
||||
.Where(kv => kv.Value == rootId)
|
||||
.Select(kv => kv.Key)
|
||||
.ToList();
|
||||
var groupIds = rootIndex.Where(kv => kv.Value == rootId).Select(kv => kv.Key).ToList();
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
|
||||
@@ -211,18 +209,13 @@ public static class AdminEndpoints
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var affectedPlayerIds = await db.Votes
|
||||
.Where(v => groupIds.Contains(v.SuggestionId))
|
||||
.Select(v => v.PlayerId)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
var affectedPlayerIds = await db.Votes.Where(v => groupIds.Contains(v.SuggestionId)).Select(v => v.PlayerId).Distinct().ToListAsync();
|
||||
|
||||
await db.Votes.Where(v => groupIds.Contains(v.SuggestionId)).ExecuteDeleteAsync();
|
||||
|
||||
if (affectedPlayerIds.Count > 0)
|
||||
{
|
||||
await db.Players.Where(p => affectedPlayerIds.Contains(p.Id))
|
||||
.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
|
||||
await db.Players.Where(p => affectedPlayerIds.Contains(p.Id)).ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
|
||||
}
|
||||
|
||||
await tx.CommitAsync();
|
||||
@@ -234,23 +227,26 @@ public static class AdminEndpoints
|
||||
});
|
||||
});
|
||||
|
||||
admin.MapPost("/reset", async (HttpContext ctx, AppDbContext db) =>
|
||||
admin.MapPost("/reset", async (HttpContext _, AppDbContext db) =>
|
||||
{
|
||||
await db.Votes.ExecuteDeleteAsync();
|
||||
await db.Suggestions.ExecuteDeleteAsync();
|
||||
|
||||
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest)
|
||||
.SetProperty(x => x.VotesFinal, false)
|
||||
.SetProperty(x => x.HasJoker, false));
|
||||
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest).SetProperty(x => x.VotesFinal, false).SetProperty(x => x.HasJoker, false));
|
||||
var state = await db.AppState.FirstAsync();
|
||||
state.ResultsOpen = false;
|
||||
state.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new { Phase = Phase.Suggest, state.ResultsOpen, state.UpdatedAt });
|
||||
return Results.Ok(new
|
||||
{
|
||||
Phase = Phase.Suggest,
|
||||
state.ResultsOpen,
|
||||
state.UpdatedAt
|
||||
});
|
||||
});
|
||||
|
||||
admin.MapPost("/factory-reset", async (HttpContext ctx, AppDbContext db) =>
|
||||
admin.MapPost("/factory-reset", async (HttpContext _, AppDbContext db) =>
|
||||
{
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
|
||||
@@ -265,7 +261,12 @@ public static class AdminEndpoints
|
||||
|
||||
await tx.CommitAsync();
|
||||
|
||||
return Results.Ok(new { Phase = Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt });
|
||||
return Results.Ok(new
|
||||
{
|
||||
Phase = Phase.Suggest,
|
||||
fresh.ResultsOpen,
|
||||
fresh.UpdatedAt
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ public static class AuthEndpoints
|
||||
|
||||
group.MapPost("/register", async ([FromBody] RegisterRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
||||
{
|
||||
var username = request.Username?.Trim();
|
||||
var username = request.Username.Trim();
|
||||
if (string.IsNullOrWhiteSpace(username) || username.Length > 24)
|
||||
return Results.BadRequest(new { error = "Username is required and must be <= 24 characters." });
|
||||
|
||||
@@ -28,6 +28,7 @@ public static class AuthEndpoints
|
||||
var displayName = EndpointHelpers.TrimTo(request.DisplayName, 16);
|
||||
if (string.IsNullOrWhiteSpace(displayName))
|
||||
return Results.BadRequest(new { error = "Display name is required." });
|
||||
|
||||
var normalized = username.ToLowerInvariant();
|
||||
|
||||
var exists = await db.Players.AnyAsync(p => p.NormalizedUsername == normalized);
|
||||
@@ -43,6 +44,7 @@ public static class AuthEndpoints
|
||||
if (string.IsNullOrWhiteSpace(expectedAdminKey) || adminKey != expectedAdminKey)
|
||||
return Results.BadRequest(new { error = "Invalid admin key." });
|
||||
}
|
||||
|
||||
var isAdmin = wantsAdmin;
|
||||
|
||||
var player = new Player
|
||||
@@ -63,12 +65,18 @@ public static class AuthEndpoints
|
||||
|
||||
await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player);
|
||||
|
||||
return Results.Ok(new { player.Id, player.Username, player.DisplayName, player.IsAdmin });
|
||||
return Results.Ok(new
|
||||
{
|
||||
player.Id,
|
||||
player.Username,
|
||||
player.DisplayName,
|
||||
player.IsAdmin
|
||||
});
|
||||
});
|
||||
|
||||
group.MapPost("/login", async ([FromBody] LoginRequest request, HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var username = request.Username?.Trim();
|
||||
var username = request.Username.Trim();
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password))
|
||||
return Results.BadRequest(new { error = "Username and password are required." });
|
||||
if (username.Length > 24)
|
||||
@@ -83,12 +91,19 @@ public static class AuthEndpoints
|
||||
{
|
||||
player.DisplayName = EndpointHelpers.TrimTo(player.Username, 16);
|
||||
}
|
||||
|
||||
player.LastLoginAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player);
|
||||
|
||||
return Results.Ok(new { player.Id, player.Username, player.DisplayName, player.IsAdmin });
|
||||
return Results.Ok(new
|
||||
{
|
||||
player.Id,
|
||||
player.Username,
|
||||
player.DisplayName,
|
||||
player.IsAdmin
|
||||
});
|
||||
});
|
||||
|
||||
group.MapPost("/logout", async (HttpContext ctx) =>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using GameList.Data;
|
||||
using GameList.Domain;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Claims;
|
||||
|
||||
@@ -11,7 +9,8 @@ internal static class EndpointHelpers
|
||||
{
|
||||
public static async Task<Player?> GetAuthenticatedPlayer(HttpContext ctx, AppDbContext db)
|
||||
{
|
||||
if (ctx?.User?.Identity?.IsAuthenticated != true) return null;
|
||||
if (ctx.User.Identity?.IsAuthenticated != true)
|
||||
return null;
|
||||
|
||||
if (ctx.Items.TryGetValue(nameof(Player), out var cached) && cached is Player cachedPlayer)
|
||||
return cachedPlayer;
|
||||
@@ -38,7 +37,8 @@ internal static class EndpointHelpers
|
||||
public static async Task<Phase> GetPhase(AppDbContext db, Guid playerId)
|
||||
{
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
|
||||
if (player is null) return Phase.Suggest;
|
||||
if (player is null)
|
||||
return Phase.Suggest;
|
||||
|
||||
var state = await db.AppState.FirstAsync();
|
||||
|
||||
@@ -68,6 +68,7 @@ internal static class EndpointHelpers
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return player.CurrentPhase;
|
||||
}
|
||||
|
||||
@@ -75,58 +76,67 @@ internal static class EndpointHelpers
|
||||
Results.BadRequest(new { error = $"This endpoint is available in the {required} phase. Your current phase is {current}." });
|
||||
|
||||
public static string? TrimTo(string? input, int max) =>
|
||||
string.IsNullOrWhiteSpace(input)
|
||||
? null
|
||||
: input.Trim() is var t && t.Length > 0
|
||||
? t[..Math.Min(t.Length, max)]
|
||||
: null;
|
||||
string.IsNullOrWhiteSpace(input) ? null : input.Trim() is { Length: > 0 } t ? t[..Math.Min(t.Length, max)] : null;
|
||||
|
||||
public static bool IsValidImageUrl(string? url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)) return true; // empty is acceptable
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false;
|
||||
if (uri.Scheme is not ("http" or "https")) return false;
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
return true; // empty is acceptable
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
return false;
|
||||
if (uri.Scheme is not ("http" or "https"))
|
||||
return false;
|
||||
|
||||
var path = uri.AbsolutePath.ToLowerInvariant();
|
||||
return path.EndsWith(".png") || path.EndsWith(".jpg") || path.EndsWith(".jpeg")
|
||||
|| path.EndsWith(".gif") || path.EndsWith(".webp") || path.EndsWith(".avif");
|
||||
return path.EndsWith(".png") || path.EndsWith(".jpg") || path.EndsWith(".jpeg") || path.EndsWith(".gif") || path.EndsWith(".webp") || path.EndsWith(".avif");
|
||||
}
|
||||
|
||||
public static async Task<bool> IsReachableImageAsync(string? url, IHttpClientFactory httpFactory, HttpMessageHandler? handler = null, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)) return true;
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false;
|
||||
if (uri.Scheme is not ("http" or "https")) return false;
|
||||
if (!await IsSafePublicHostAsync(uri, httpFactory, ct)) return false;
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
return true;
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
return false;
|
||||
if (uri.Scheme is not ("http" or "https"))
|
||||
return false;
|
||||
if (!await IsSafePublicHostAsync(uri, ct))
|
||||
return false;
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(3));
|
||||
|
||||
var client = handler is null
|
||||
? httpFactory.CreateClient("imageValidation")
|
||||
: new HttpClient(handler, disposeHandler: false);
|
||||
var client = handler is null ? httpFactory.CreateClient("imageValidation") : new HttpClient(handler, disposeHandler: false);
|
||||
|
||||
try
|
||||
{
|
||||
using var head = new HttpRequestMessage(HttpMethod.Head, uri);
|
||||
var headResp = await client.SendAsync(head, HttpCompletionOption.ResponseHeadersRead, cts.Token);
|
||||
if (headResp.IsSuccessStatusCode && headResp.StatusCode is not System.Net.HttpStatusCode.Redirect)
|
||||
if (headResp is { IsSuccessStatusCode: true, StatusCode: not System.Net.HttpStatusCode.Redirect })
|
||||
{
|
||||
if (headResp.Content.Headers.ContentLength is long headLen && headLen > MaxImageBytes) return false;
|
||||
if (headResp.Content.Headers.ContentLength is > MaxImageBytes)
|
||||
return false;
|
||||
|
||||
var ctHeader = headResp.Content.Headers.ContentType?.MediaType;
|
||||
if (!string.IsNullOrWhiteSpace(ctHeader) && ctHeader.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch { /* fallback */ }
|
||||
catch
|
||||
{
|
||||
/* fallback */
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var get = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
get.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(0, 1023);
|
||||
var resp = await client.SendAsync(get, HttpCompletionOption.ResponseHeadersRead, cts.Token);
|
||||
if (!resp.IsSuccessStatusCode) return false;
|
||||
if (resp.StatusCode is System.Net.HttpStatusCode.Redirect) return false;
|
||||
if (resp.Content.Headers.ContentLength is long len && len > MaxImageBytes) return false;
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
return false;
|
||||
if (resp.StatusCode is System.Net.HttpStatusCode.Redirect)
|
||||
return false;
|
||||
if (resp.Content.Headers.ContentLength is > MaxImageBytes)
|
||||
return false;
|
||||
|
||||
var ctHeader = resp.Content.Headers.ContentType?.MediaType;
|
||||
if (!string.IsNullOrWhiteSpace(ctHeader) && ctHeader.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -137,11 +147,16 @@ internal static class EndpointHelpers
|
||||
var read = await stream.ReadAsync(rented, 0, rented.Length, cts.Token);
|
||||
var sig = new ReadOnlySpan<byte>(rented, 0, read);
|
||||
|
||||
if (IsMagic(sig, "PNG")) return true;
|
||||
if (IsMagic(sig, new byte[] { 0xFF, 0xD8 })) return true; // JPEG
|
||||
if (IsMagic(sig, "GIF8")) return true;
|
||||
if (IsRiffWithTag(sig, "WEBP")) return true;
|
||||
if (ContainsFtyp(sig, "avif")) return true;
|
||||
if (IsMagic(sig, "PNG"))
|
||||
return true;
|
||||
if (IsMagic(sig, [0xFF, 0xD8]))
|
||||
return true; // JPEG
|
||||
if (IsMagic(sig, "GIF8"))
|
||||
return true;
|
||||
if (IsRiffWithTag(sig, "WEBP"))
|
||||
return true;
|
||||
if (ContainsFtyp(sig, "avif"))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -153,7 +168,7 @@ internal static class EndpointHelpers
|
||||
|
||||
private const long MaxImageBytes = 5 * 1024 * 1024; // 5 MB guard
|
||||
|
||||
private static async Task<bool> IsSafePublicHostAsync(Uri uri, IHttpClientFactory httpFactory, CancellationToken ct)
|
||||
private static async Task<bool> IsSafePublicHostAsync(Uri uri, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -163,8 +178,10 @@ internal static class EndpointHelpers
|
||||
var addresses = await System.Net.Dns.GetHostAddressesAsync(host, ct);
|
||||
foreach (var ip in addresses)
|
||||
{
|
||||
if (System.Net.IPAddress.IsLoopback(ip)) return false;
|
||||
if (IsPrivate(ip)) return false;
|
||||
if (System.Net.IPAddress.IsLoopback(ip))
|
||||
return false;
|
||||
if (IsPrivate(ip))
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -187,11 +204,11 @@ internal static class EndpointHelpers
|
||||
var bytes = ip.GetAddressBytes();
|
||||
return bytes[0] switch
|
||||
{
|
||||
10 => true,
|
||||
10 => true,
|
||||
172 when bytes[1] >= 16 && bytes[1] <= 31 => true,
|
||||
192 when bytes[1] == 168 => true,
|
||||
127 => true,
|
||||
_ => false
|
||||
192 when bytes[1] == 168 => true,
|
||||
127 => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -213,26 +230,37 @@ internal static class EndpointHelpers
|
||||
|
||||
private static bool IsRiffWithTag(ReadOnlySpan<byte> data, string tag)
|
||||
{
|
||||
if (data.Length < 12) return false;
|
||||
var riff = System.Text.Encoding.ASCII.GetBytes("RIFF");
|
||||
if (!data.StartsWith(riff)) return false;
|
||||
if (data.Length < 12)
|
||||
return false;
|
||||
|
||||
var riff = "RIFF"u8.ToArray();
|
||||
if (!data.StartsWith(riff))
|
||||
return false;
|
||||
|
||||
var tagBytes = System.Text.Encoding.ASCII.GetBytes(tag);
|
||||
return data[8..].StartsWith(tagBytes);
|
||||
}
|
||||
|
||||
private static bool ContainsFtyp(ReadOnlySpan<byte> data, string brand)
|
||||
{
|
||||
if (data.Length < 12) return false;
|
||||
var ftyp = System.Text.Encoding.ASCII.GetBytes("ftyp");
|
||||
if (!data[4..].StartsWith(ftyp)) return false;
|
||||
if (data.Length < 12)
|
||||
return false;
|
||||
|
||||
var ftyp = "ftyp"u8.ToArray();
|
||||
if (!data[4..].StartsWith(ftyp))
|
||||
return false;
|
||||
|
||||
var brandBytes = System.Text.Encoding.ASCII.GetBytes(brand);
|
||||
return data[8..].StartsWith(brandBytes);
|
||||
}
|
||||
|
||||
public static bool IsValidHttpUrl(string? url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url)) return true; // empty is allowed
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false;
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
return true; // empty is allowed
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
return false;
|
||||
|
||||
return uri.Scheme is "http" or "https";
|
||||
}
|
||||
|
||||
@@ -257,6 +285,7 @@ internal static class EndpointHelpers
|
||||
{
|
||||
roots[id] = FindRootId(id, parentMap);
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
@@ -265,7 +294,7 @@ internal static class EndpointHelpers
|
||||
var current = suggestionId;
|
||||
var visited = new HashSet<int>();
|
||||
|
||||
while (parentMap.TryGetValue(current, out var parent) && parent is int p && !visited.Contains(p))
|
||||
while (parentMap.TryGetValue(current, out var parent) && parent is { } p && !visited.Contains(p))
|
||||
{
|
||||
visited.Add(current);
|
||||
current = p;
|
||||
@@ -276,7 +305,9 @@ internal static class EndpointHelpers
|
||||
|
||||
public static List<int> LinkedIdsFor(int suggestionId, IReadOnlyDictionary<int, int> rootIndex)
|
||||
{
|
||||
if (!rootIndex.TryGetValue(suggestionId, out var root)) return new();
|
||||
if (!rootIndex.TryGetValue(suggestionId, out var root))
|
||||
return [];
|
||||
|
||||
return rootIndex.Where(kv => kv.Value == root).Select(kv => kv.Key).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ public static class ResultsEndpoints
|
||||
s.MinPlayers,
|
||||
s.MaxPlayers,
|
||||
Total = s.Votes.Sum(v => v.Score),
|
||||
Count = s.Votes.Count,
|
||||
s.Votes.Count,
|
||||
Average = s.Votes.Count == 0 ? 0 : s.Votes.Average(v => v.Score),
|
||||
Votes = s.Votes.Select(v => v.Score).ToList(),
|
||||
MyVote = s.Votes
|
||||
@@ -85,7 +85,7 @@ public static class ResultsEndpoints
|
||||
r.ParentSuggestionId,
|
||||
LinkedIds = linkedIds,
|
||||
LinkedTitles = linkedIds
|
||||
.Where(id => nameLookup.ContainsKey(id))
|
||||
.Where(nameLookup.ContainsKey)
|
||||
.Select(id => nameLookup[id])
|
||||
.ToList()
|
||||
};
|
||||
|
||||
@@ -15,7 +15,9 @@ public static class StateEndpoints
|
||||
group.MapGet("/state", async (HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
|
||||
var state = await db.AppState.AsNoTracking().FirstAsync();
|
||||
@@ -36,16 +38,27 @@ public static class StateEndpoints
|
||||
group.MapGet("/me", async (HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
return Results.Ok(new { player.Id, player.DisplayName, player.Username, player.IsAdmin, CurrentPhase = phase, player.VotesFinal, player.HasJoker });
|
||||
return Results.Ok(new
|
||||
{
|
||||
player.Id,
|
||||
player.DisplayName,
|
||||
player.Username,
|
||||
player.IsAdmin,
|
||||
CurrentPhase = phase,
|
||||
player.VotesFinal,
|
||||
player.HasJoker
|
||||
});
|
||||
});
|
||||
|
||||
group.MapPost("/me/phase/next", async (HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
||||
group.MapPost("/me/phase/next", async (HttpContext ctx, AppDbContext db, IConfiguration _) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
var isAdmin = await EndpointHelpers.IsAdmin(ctx, db);
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var next = NextPhase(player.CurrentPhase);
|
||||
var appState = await db.AppState.FirstAsync();
|
||||
@@ -58,13 +71,19 @@ public static class StateEndpoints
|
||||
player.CurrentPhase = next;
|
||||
player.VotesFinal = false; // moving forward clears any prior finalize
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(new { player.CurrentPhase, appState.ResultsOpen });
|
||||
return Results.Ok(new
|
||||
{
|
||||
player.CurrentPhase,
|
||||
appState.ResultsOpen
|
||||
});
|
||||
});
|
||||
|
||||
group.MapPost("/me/phase/prev", async (HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
||||
group.MapPost("/me/phase/prev", async (HttpContext ctx, AppDbContext db, IConfiguration _) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var isAdmin = await EndpointHelpers.IsAdmin(ctx, db);
|
||||
if (!isAdmin)
|
||||
{
|
||||
@@ -75,12 +94,16 @@ public static class StateEndpoints
|
||||
player.VotesFinal = false;
|
||||
await db.SaveChangesAsync();
|
||||
var appState = await db.AppState.AsNoTracking().FirstAsync();
|
||||
return Results.Ok(new { player.CurrentPhase, appState.ResultsOpen });
|
||||
return Results.Ok(new
|
||||
{
|
||||
player.CurrentPhase,
|
||||
appState.ResultsOpen
|
||||
});
|
||||
});
|
||||
|
||||
group.MapPost("/me/name", async ([FromBody] SetNameRequest request, HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
if (request.Name?.Trim().Length > 16)
|
||||
if (request.Name.Trim().Length > 16)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Name is required and must be <= 16 characters." });
|
||||
}
|
||||
@@ -92,27 +115,32 @@ public static class StateEndpoints
|
||||
}
|
||||
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
player.DisplayName = name;
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(new { player.Id, player.DisplayName });
|
||||
return Results.Ok(new
|
||||
{
|
||||
player.Id,
|
||||
player.DisplayName
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static Phase NextPhase(Phase current) => current switch
|
||||
{
|
||||
Phase.Suggest => Phase.Vote,
|
||||
Phase.Reveal => Phase.Vote, // legacy safety
|
||||
Phase.Vote => Phase.Results,
|
||||
_ => Phase.Results
|
||||
Phase.Reveal => Phase.Vote, // legacy safety
|
||||
Phase.Vote => Phase.Results,
|
||||
_ => Phase.Results
|
||||
};
|
||||
|
||||
private static Phase PrevPhase(Phase current) => current switch
|
||||
{
|
||||
Phase.Results => Phase.Vote,
|
||||
Phase.Vote => Phase.Suggest,
|
||||
Phase.Reveal => Phase.Suggest, // legacy safety
|
||||
_ => Phase.Suggest
|
||||
Phase.Vote => Phase.Suggest,
|
||||
Phase.Reveal => Phase.Suggest, // legacy safety
|
||||
_ => Phase.Suggest
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,29 +16,26 @@ public static class SuggestEndpoints
|
||||
group.MapGet("/mine", async (HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
var mine = await db.Suggestions.AsNoTracking()
|
||||
.Where(s => s.PlayerId == player.Id)
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.PlayerId,
|
||||
s.Name,
|
||||
s.Genre,
|
||||
s.Description,
|
||||
s.ScreenshotUrl,
|
||||
s.YoutubeUrl,
|
||||
s.GameUrl,
|
||||
s.CreatedAt,
|
||||
s.MinPlayers,
|
||||
s.MaxPlayers,
|
||||
s.ParentSuggestionId
|
||||
})
|
||||
.ToListAsync();
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var ordered = mine
|
||||
.OrderBy(s => s.CreatedAt)
|
||||
.Select(s => new SuggestionDto(s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, s.MinPlayers, s.MaxPlayers, s.ParentSuggestionId));
|
||||
var mine = await db.Suggestions.AsNoTracking().Where(s => s.PlayerId == player.Id).Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.PlayerId,
|
||||
s.Name,
|
||||
s.Genre,
|
||||
s.Description,
|
||||
s.ScreenshotUrl,
|
||||
s.YoutubeUrl,
|
||||
s.GameUrl,
|
||||
s.CreatedAt,
|
||||
s.MinPlayers,
|
||||
s.MaxPlayers,
|
||||
s.ParentSuggestionId
|
||||
}).ToListAsync();
|
||||
|
||||
var ordered = mine.OrderBy(s => s.CreatedAt).Select(s => new SuggestionDto(s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, s.MinPlayers, s.MaxPlayers, s.ParentSuggestionId));
|
||||
|
||||
return Results.Ok(ordered);
|
||||
});
|
||||
@@ -54,10 +51,12 @@ public static class SuggestEndpoints
|
||||
{
|
||||
return Results.BadRequest(new { error = "Screenshot URL must be http(s) and end with an image file extension." });
|
||||
}
|
||||
|
||||
if (!await EndpointHelpers.IsReachableImageAsync(request.ScreenshotUrl, http))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Screenshot URL could not be validated as an image. Use a public image link (http/https, no redirects, max 5 MB)." });
|
||||
}
|
||||
|
||||
if (!EndpointHelpers.IsValidHttpUrl(request.GameUrl))
|
||||
return Results.BadRequest(new { error = "Game URL must be http or https." });
|
||||
if (!EndpointHelpers.IsValidHttpUrl(request.YoutubeUrl))
|
||||
@@ -67,11 +66,14 @@ public static class SuggestEndpoints
|
||||
return Results.BadRequest(new { error = playersError });
|
||||
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
var usingJoker = phase == Phase.Vote && player.HasJoker;
|
||||
if (phase != Phase.Suggest && !usingJoker)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(player.DisplayName))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Set a display name before submitting suggestions." });
|
||||
@@ -107,13 +109,14 @@ public static class SuggestEndpoints
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Created($"/api/suggestions/{suggestion.Id}", new { suggestion.Id });
|
||||
})
|
||||
.AddEndpointFilter(new PhaseOrJokerFilter());
|
||||
}).AddEndpointFilter(new PhaseOrJokerFilter());
|
||||
|
||||
group.MapDelete("/{id:int}", async (int id, HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var isAdmin = await EndpointHelpers.IsAdmin(ctx, db);
|
||||
|
||||
if (!isAdmin)
|
||||
@@ -123,16 +126,12 @@ public static class SuggestEndpoints
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||
}
|
||||
|
||||
var suggestion = isAdmin
|
||||
? await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id)
|
||||
: await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id && s.PlayerId == player.Id);
|
||||
var suggestion = isAdmin ? await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id) : await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id && s.PlayerId == player.Id);
|
||||
if (suggestion == null)
|
||||
return Results.NotFound(new { error = "Suggestion not found." });
|
||||
|
||||
// Break any links that pointed at this suggestion
|
||||
await db.Suggestions
|
||||
.Where(s => s.ParentSuggestionId == suggestion.Id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ParentSuggestionId, (int?)null));
|
||||
await db.Suggestions.Where(s => s.ParentSuggestionId == suggestion.Id).ExecuteUpdateAsync(s => s.SetProperty(x => x.ParentSuggestionId, (int?)null));
|
||||
|
||||
// Remove votes for this suggestion to avoid orphaned vote rows or FK errors
|
||||
await db.Votes.Where(v => v.SuggestionId == suggestion.Id).ExecuteDeleteAsync();
|
||||
@@ -147,7 +146,8 @@ public static class SuggestEndpoints
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
var isAdmin = await EndpointHelpers.IsAdmin(ctx, db);
|
||||
|
||||
if (!isAdmin && player is null) return Results.Unauthorized();
|
||||
if (!isAdmin && player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 100)
|
||||
{
|
||||
@@ -158,10 +158,12 @@ public static class SuggestEndpoints
|
||||
{
|
||||
return Results.BadRequest(new { error = "Screenshot URL must be http(s) and end with an image file extension." });
|
||||
}
|
||||
|
||||
if (!await EndpointHelpers.IsReachableImageAsync(request.ScreenshotUrl, http))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Screenshot URL could not be validated as an image. Use a public image link (http/https, no redirects, max 5 MB)." });
|
||||
}
|
||||
|
||||
if (!EndpointHelpers.IsValidHttpUrl(request.GameUrl))
|
||||
return Results.BadRequest(new { error = "Game URL must be http or https." });
|
||||
if (!EndpointHelpers.IsValidHttpUrl(request.YoutubeUrl))
|
||||
@@ -171,7 +173,8 @@ public static class SuggestEndpoints
|
||||
return Results.BadRequest(new { error = playersError });
|
||||
|
||||
var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id);
|
||||
if (suggestion == null) return Results.NotFound(new { error = "Suggestion not found." });
|
||||
if (suggestion == null)
|
||||
return Results.NotFound(new { error = "Suggestion not found." });
|
||||
|
||||
if (!isAdmin)
|
||||
{
|
||||
@@ -238,14 +241,38 @@ public static class SuggestEndpoints
|
||||
group.MapGet("/all", async (HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
if (phase < Phase.Vote)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||
|
||||
var all = await db.Suggestions.AsNoTracking()
|
||||
.Include(s => s.Player)
|
||||
.Select(s => new
|
||||
var all = await db.Suggestions.AsNoTracking().Include(s => s.Player).Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.Name,
|
||||
s.Genre,
|
||||
s.Description,
|
||||
s.ScreenshotUrl,
|
||||
s.YoutubeUrl,
|
||||
s.GameUrl,
|
||||
s.MinPlayers,
|
||||
s.MaxPlayers,
|
||||
Author = s.Player!.DisplayName,
|
||||
s.CreatedAt,
|
||||
s.ParentSuggestionId,
|
||||
IsOwner = s.PlayerId == player.Id
|
||||
}).ToListAsync();
|
||||
|
||||
var rootIndex = EndpointHelpers.BuildLinkRoots(all.Select(s => (s.Id, s.ParentSuggestionId)));
|
||||
var nameLookup = all.ToDictionary(s => s.Id, s => s.Name);
|
||||
|
||||
var ordered = all.OrderBy(s => s.CreatedAt).Select(s =>
|
||||
{
|
||||
var linkedIds = EndpointHelpers.LinkedIdsFor(s.Id, rootIndex).Where(id => id != s.Id).ToList();
|
||||
|
||||
return new
|
||||
{
|
||||
s.Id,
|
||||
s.Name,
|
||||
@@ -256,45 +283,13 @@ public static class SuggestEndpoints
|
||||
s.GameUrl,
|
||||
s.MinPlayers,
|
||||
s.MaxPlayers,
|
||||
Author = s.Player!.DisplayName,
|
||||
s.CreatedAt,
|
||||
s.Author,
|
||||
s.ParentSuggestionId,
|
||||
IsOwner = s.PlayerId == player.Id
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var rootIndex = EndpointHelpers.BuildLinkRoots(all.Select(s => (s.Id, s.ParentSuggestionId)));
|
||||
var nameLookup = all.ToDictionary(s => s.Id, s => s.Name);
|
||||
|
||||
var ordered = all
|
||||
.OrderBy(s => s.CreatedAt)
|
||||
.Select(s =>
|
||||
{
|
||||
var linkedIds = EndpointHelpers.LinkedIdsFor(s.Id, rootIndex)
|
||||
.Where(id => id != s.Id)
|
||||
.ToList();
|
||||
|
||||
return new
|
||||
{
|
||||
s.Id,
|
||||
s.Name,
|
||||
s.Genre,
|
||||
s.Description,
|
||||
s.ScreenshotUrl,
|
||||
s.YoutubeUrl,
|
||||
s.GameUrl,
|
||||
s.MinPlayers,
|
||||
s.MaxPlayers,
|
||||
s.Author,
|
||||
s.ParentSuggestionId,
|
||||
s.IsOwner,
|
||||
LinkedIds = linkedIds,
|
||||
LinkedTitles = linkedIds
|
||||
.Where(id => nameLookup.ContainsKey(id))
|
||||
.Select(id => nameLookup[id])
|
||||
.ToList()
|
||||
};
|
||||
});
|
||||
s.IsOwner,
|
||||
LinkedIds = linkedIds,
|
||||
LinkedTitles = linkedIds.Where(nameLookup.ContainsKey).Select(id => nameLookup[id]).ToList()
|
||||
};
|
||||
});
|
||||
|
||||
return Results.Ok(ordered);
|
||||
});
|
||||
@@ -303,15 +298,16 @@ public static class SuggestEndpoints
|
||||
private static bool ValidatePlayers(int? minPlayers, int? maxPlayers, out string? error)
|
||||
{
|
||||
error = null;
|
||||
if (minPlayers is null && maxPlayers is null) return true;
|
||||
if (minPlayers is null && maxPlayers is null)
|
||||
return true;
|
||||
|
||||
if (minPlayers is not null && (minPlayers < 1 || minPlayers > 32))
|
||||
if (minPlayers is < 1 or > 32)
|
||||
{
|
||||
error = "Min players must be between 1 and 32.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (maxPlayers is not null && (maxPlayers < 1 || maxPlayers > 32))
|
||||
if (maxPlayers is < 1 or > 32)
|
||||
{
|
||||
error = "Max players must be between 1 and 32.";
|
||||
return false;
|
||||
|
||||
@@ -11,21 +11,23 @@ public static class VoteEndpoints
|
||||
{
|
||||
public static void MapVoteEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/votes")
|
||||
.RequireAuthorization()
|
||||
.AddEndpointFilter(new PhaseRequirementFilter(Phase.Vote));
|
||||
var group = app.MapGroup("/api/votes").RequireAuthorization().AddEndpointFilter(new PhaseRequirementFilter(Phase.Vote));
|
||||
|
||||
group.MapGet("/mine", async (HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
if (phase != Phase.Vote)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||
var votes = await db.Votes.AsNoTracking()
|
||||
.Where(v => v.PlayerId == player.Id)
|
||||
.Select(v => new { v.SuggestionId, v.Score })
|
||||
.ToListAsync();
|
||||
|
||||
var votes = await db.Votes.AsNoTracking().Where(v => v.PlayerId == player.Id).Select(v => new
|
||||
{
|
||||
v.SuggestionId,
|
||||
v.Score
|
||||
}).ToListAsync();
|
||||
|
||||
return Results.Ok(votes);
|
||||
});
|
||||
@@ -36,9 +38,11 @@ public static class VoteEndpoints
|
||||
return Results.BadRequest(new { error = "Score must be between 0 and 10." });
|
||||
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
if (player.VotesFinal)
|
||||
return Results.BadRequest(new { error = "Votes are finalized. Unfinalize before changing scores." });
|
||||
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
if (phase != Phase.Vote)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||
@@ -46,19 +50,20 @@ public static class VoteEndpoints
|
||||
if (string.IsNullOrWhiteSpace(player.DisplayName))
|
||||
return Results.BadRequest(new { error = "Set a display name before voting." });
|
||||
|
||||
var linkMap = await db.Suggestions.AsNoTracking()
|
||||
.Select(s => new { s.Id, s.ParentSuggestionId })
|
||||
.ToListAsync();
|
||||
var linkMap = await db.Suggestions.AsNoTracking().Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.ParentSuggestionId
|
||||
}).ToListAsync();
|
||||
var rootIndex = EndpointHelpers.BuildLinkRoots(linkMap.Select(s => (s.Id, s.ParentSuggestionId)));
|
||||
if (!rootIndex.ContainsKey(request.SuggestionId))
|
||||
return Results.BadRequest(new { error = "Suggestion not found." });
|
||||
|
||||
var linkedIds = EndpointHelpers.LinkedIdsFor(request.SuggestionId, rootIndex);
|
||||
if (linkedIds.Count == 0)
|
||||
linkedIds.Add(request.SuggestionId);
|
||||
|
||||
var existingVotes = await db.Votes
|
||||
.Where(v => v.PlayerId == player.Id && linkedIds.Contains(v.SuggestionId))
|
||||
.ToListAsync();
|
||||
var existingVotes = await db.Votes.Where(v => v.PlayerId == player.Id && linkedIds.Contains(v.SuggestionId)).ToListAsync();
|
||||
|
||||
foreach (var suggestionId in linkedIds)
|
||||
{
|
||||
@@ -79,13 +84,19 @@ public static class VoteEndpoints
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(new { SuggestionIds = linkedIds, request.Score });
|
||||
return Results.Ok(new
|
||||
{
|
||||
SuggestionIds = linkedIds,
|
||||
request.Score
|
||||
});
|
||||
});
|
||||
|
||||
group.MapPost("/finalize", async ([FromBody] VoteFinalizeRequest request, HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null) return Results.Unauthorized();
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var phase = await EndpointHelpers.GetPhase(db, player.Id);
|
||||
if (phase != Phase.Vote)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||
|
||||
Reference in New Issue
Block a user