using GameList.Data; using GameList.Domain; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace GameList.Endpoints; internal static class EndpointHelpers { public static async Task 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 GetPhase(AppDbContext db) { var state = await db.AppState.AsNoTracking().FirstAsync(); return state.CurrentPhase; } public static IResult PhaseMismatch(Phase required, Phase current) => Results.BadRequest(new { error = $"This endpoint is available in the {required} phase. 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 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); Span buffer = stackalloc byte[12]; var read = await stream.ReadAsync(buffer, cts.Token); var sig = buffer[..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 data, string ascii) { var bytes = System.Text.Encoding.ASCII.GetBytes(ascii); return data.StartsWith(bytes); } private static bool IsMagic(ReadOnlySpan data, ReadOnlySpan magic) => data.StartsWith(magic); private static bool IsRiffWithTag(ReadOnlySpan 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 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 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, CurrentPhase = Phase.Suggest, UpdatedAt = DateTimeOffset.UnixEpoch }; }