154 lines
5.9 KiB
C#
154 lines
5.9 KiB
C#
using GameList.Data;
|
|
using GameList.Domain;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace GameList.Endpoints;
|
|
|
|
internal static class EndpointHelpers
|
|
{
|
|
public static async Task<Player?> GetAuthenticatedPlayer(HttpContext ctx, AppDbContext db)
|
|
{
|
|
if (!ctx.Items.TryGetValue(Infrastructure.PlayerIdentityExtensions.PlayerCookieName, out var value) || value is not Guid playerId)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var existing = await db.Players.FindAsync(playerId);
|
|
return existing;
|
|
}
|
|
|
|
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;
|
|
|
|
// Auto-upgrade any legacy Reveal phase to Vote to avoid blank screens
|
|
if (player.CurrentPhase == Phase.Reveal)
|
|
{
|
|
player.CurrentPhase = Phase.Vote;
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
return player.CurrentPhase;
|
|
}
|
|
|
|
public static IResult PhaseMismatch(Phase required, Phase current) =>
|
|
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;
|
|
|
|
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;
|
|
var path = uri.AbsolutePath.ToLowerInvariant();
|
|
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, 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;
|
|
|
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
cts.CancelAfter(TimeSpan.FromSeconds(3));
|
|
|
|
var client = httpFactory.CreateClient();
|
|
|
|
try
|
|
{
|
|
using var head = new HttpRequestMessage(HttpMethod.Head, uri);
|
|
var headResp = await client.SendAsync(head, HttpCompletionOption.ResponseHeadersRead, cts.Token);
|
|
if (headResp.IsSuccessStatusCode)
|
|
{
|
|
var ctHeader = headResp.Content.Headers.ContentType?.MediaType;
|
|
if (!string.IsNullOrWhiteSpace(ctHeader) && ctHeader.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
|
|
return true;
|
|
}
|
|
}
|
|
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;
|
|
|
|
var ctHeader = resp.Content.Headers.ContentType?.MediaType;
|
|
if (!string.IsNullOrWhiteSpace(ctHeader) && ctHeader.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
|
|
return true;
|
|
|
|
await using var stream = await resp.Content.ReadAsStreamAsync(cts.Token);
|
|
var rented = new byte[12];
|
|
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;
|
|
|
|
return false;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static bool IsMagic(ReadOnlySpan<byte> data, string ascii)
|
|
{
|
|
var bytes = System.Text.Encoding.ASCII.GetBytes(ascii);
|
|
return data.StartsWith(bytes);
|
|
}
|
|
|
|
private static bool IsMagic(ReadOnlySpan<byte> data, ReadOnlySpan<byte> magic) => data.StartsWith(magic);
|
|
|
|
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;
|
|
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;
|
|
var brandBytes = System.Text.Encoding.ASCII.GetBytes(brand);
|
|
return data[8..].StartsWith(brandBytes);
|
|
}
|
|
|
|
public static async Task<bool> IsAdmin(HttpContext ctx, AppDbContext db, IConfiguration config)
|
|
{
|
|
var player = await GetAuthenticatedPlayer(ctx, db);
|
|
if (player?.IsAdmin == true) return true;
|
|
|
|
var provided = ctx.Request.Headers["X-Admin-Key"].FirstOrDefault()
|
|
?? ctx.Request.Query["key"].FirstOrDefault();
|
|
var expected = config["ADMIN_PASSWORD"];
|
|
return !string.IsNullOrWhiteSpace(expected) && provided == expected;
|
|
}
|
|
|
|
public static AppState NewAppState() => new()
|
|
{
|
|
Id = 1,
|
|
ResultsOpen = false,
|
|
UpdatedAt = DateTimeOffset.UnixEpoch
|
|
};
|
|
}
|