510 lines
17 KiB
C#
510 lines
17 KiB
C#
using GameList.Data;
|
|
using GameList.Domain;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using System.Net;
|
|
using System.Net.Sockets;
|
|
using System.Security.Claims;
|
|
|
|
namespace GameList.Endpoints;
|
|
|
|
internal static class EndpointHelpers
|
|
{
|
|
public const string SingleOwnerIndexName = "IX_Players_IsOwner";
|
|
public const string SuggestionLimitTriggerError = "suggestion_limit_exceeded";
|
|
|
|
public static async Task<Player?> GetAuthenticatedPlayer(HttpContext ctx, AppDbContext db)
|
|
{
|
|
if (ctx.User.Identity?.IsAuthenticated != true)
|
|
return null;
|
|
|
|
if (ctx.Items.TryGetValue(nameof(Player), out var cached) && cached is Player cachedPlayer)
|
|
return cachedPlayer;
|
|
|
|
var idValue = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
if (string.IsNullOrWhiteSpace(idValue) || !Guid.TryParse(idValue, out var playerId))
|
|
{
|
|
// Auth cookie is present but malformed; clear and reject.
|
|
await Infrastructure.PlayerIdentityExtensions.SignOutPlayerAsync(ctx);
|
|
return null;
|
|
}
|
|
|
|
var existing = await db.Players.FindAsync(playerId);
|
|
if (existing is null)
|
|
{
|
|
await Infrastructure.PlayerIdentityExtensions.SignOutPlayerAsync(ctx);
|
|
return null;
|
|
}
|
|
|
|
ctx.Items[nameof(Player)] = existing;
|
|
return existing;
|
|
}
|
|
|
|
public static async Task<Phase> GetCurrentPhaseAsync(AppDbContext db, Guid playerId)
|
|
{
|
|
var playerPhase = await db.Players
|
|
.AsNoTracking()
|
|
.Where(p => p.Id == playerId)
|
|
.Select(p => (Phase?)p.CurrentPhase)
|
|
.FirstOrDefaultAsync();
|
|
if (playerPhase is null)
|
|
return Phase.Suggest;
|
|
|
|
var resultsOpen = await db.AppState.AsNoTracking().Select(s => s.ResultsOpen).SingleAsync();
|
|
return GetCurrentPhase(playerPhase.Value, resultsOpen);
|
|
}
|
|
|
|
public static Phase GetCurrentPhase(Phase phase, bool resultsOpen)
|
|
{
|
|
var normalized = NormalizePhase(phase);
|
|
|
|
if (resultsOpen)
|
|
return Phase.Results;
|
|
|
|
return normalized == Phase.Results ? Phase.Vote : normalized;
|
|
}
|
|
|
|
public static bool ReconcilePlayerPhase(Player player, bool resultsOpen)
|
|
{
|
|
var changed = false;
|
|
|
|
var normalized = NormalizePhase(player.CurrentPhase);
|
|
if (player.CurrentPhase != normalized)
|
|
{
|
|
player.CurrentPhase = normalized;
|
|
changed = true;
|
|
}
|
|
|
|
if (resultsOpen && player.CurrentPhase != Phase.Results)
|
|
{
|
|
player.CurrentPhase = Phase.Results;
|
|
changed = true;
|
|
}
|
|
else if (!resultsOpen && player.CurrentPhase == Phase.Results)
|
|
{
|
|
player.CurrentPhase = Phase.Vote;
|
|
player.VotesFinal = false;
|
|
changed = true;
|
|
}
|
|
|
|
return changed;
|
|
}
|
|
|
|
private static Phase NormalizePhase(Phase phase)
|
|
{
|
|
return phase switch
|
|
{
|
|
Phase.Suggest => Phase.Suggest,
|
|
Phase.Vote => Phase.Vote,
|
|
Phase.Results => Phase.Results,
|
|
_ => Phase.Vote // legacy/invalid phase fallback
|
|
};
|
|
}
|
|
|
|
public static IResult PhaseMismatch(Phase required, Phase current) =>
|
|
BadRequestError($"This endpoint is available in the {required} phase. Your current phase is {current}.");
|
|
|
|
public static IResult BadRequestError(string detail) => Problem(StatusCodes.Status400BadRequest, "Bad Request", detail);
|
|
|
|
public static IResult NotFoundError(string detail) => Problem(StatusCodes.Status404NotFound, "Not Found", detail);
|
|
|
|
public static IResult ConflictError(string detail) => Problem(StatusCodes.Status409Conflict, "Conflict", detail);
|
|
|
|
public static IResult UnauthorizedError(string detail = "Unauthorized") => Problem(StatusCodes.Status401Unauthorized, "Unauthorized", detail);
|
|
|
|
public static IResult ToHttpResult<T>(this ServiceResult<T> result, Func<T, IResult> onSuccess)
|
|
{
|
|
if (result.IsSuccess)
|
|
return onSuccess(result.Value!);
|
|
|
|
return ToHttpError(result.Error!);
|
|
}
|
|
|
|
public static IResult ToHttpResult(this ServiceResult<Unit> result, Func<IResult> onSuccess)
|
|
{
|
|
if (result.IsSuccess)
|
|
return onSuccess();
|
|
|
|
return ToHttpError(result.Error!);
|
|
}
|
|
|
|
public static bool IsSqliteConstraintViolation(DbUpdateException ex)
|
|
{
|
|
return ex.InnerException is SqliteException sqliteEx
|
|
&& sqliteEx.SqliteErrorCode == 19;
|
|
}
|
|
|
|
public static bool IsSqliteConstraintViolation(DbUpdateException ex, string containsMessage)
|
|
{
|
|
if (!IsSqliteConstraintViolation(ex))
|
|
return false;
|
|
|
|
return ex.InnerException?.Message.Contains(containsMessage, StringComparison.OrdinalIgnoreCase) == true;
|
|
}
|
|
|
|
private static IResult Problem(int statusCode, string title, string detail)
|
|
{
|
|
return Results.Problem(
|
|
statusCode: statusCode,
|
|
title: title,
|
|
detail: detail,
|
|
extensions: new Dictionary<string, object?>
|
|
{
|
|
["error"] = detail
|
|
}
|
|
);
|
|
}
|
|
|
|
public static string? TrimTo(string? input, int max) =>
|
|
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;
|
|
|
|
var path = uri.AbsolutePath.ToLowerInvariant();
|
|
return path.EndsWith(".png", StringComparison.Ordinal)
|
|
|| path.EndsWith(".jpg", StringComparison.Ordinal)
|
|
|| path.EndsWith(".jpeg", StringComparison.Ordinal)
|
|
|| path.EndsWith(".gif", StringComparison.Ordinal)
|
|
|| path.EndsWith(".webp", StringComparison.Ordinal)
|
|
|| path.EndsWith(".avif", StringComparison.Ordinal);
|
|
}
|
|
|
|
private static IResult ToHttpError(ServiceError error)
|
|
{
|
|
return error.Code switch
|
|
{
|
|
ServiceErrorCode.BadRequest => BadRequestError(error.Detail),
|
|
ServiceErrorCode.Unauthorized => UnauthorizedError(error.Detail),
|
|
ServiceErrorCode.NotFound => NotFoundError(error.Detail),
|
|
ServiceErrorCode.Conflict => ConflictError(error.Detail),
|
|
_ => Problem(StatusCodes.Status500InternalServerError, "Internal Server Error", "Unhandled service error.")
|
|
};
|
|
}
|
|
|
|
public static HttpMessageHandler CreateImageValidationHandler()
|
|
{
|
|
return new SocketsHttpHandler
|
|
{
|
|
AllowAutoRedirect = false,
|
|
ConnectCallback = async (context, cancellationToken) =>
|
|
{
|
|
var addresses = await ResolveSafePublicAddressesAsync(context.DnsEndPoint.Host, cancellationToken);
|
|
if (addresses.Count == 0)
|
|
throw new HttpRequestException("No safe public IPs found for host.");
|
|
|
|
foreach (var ip in addresses)
|
|
{
|
|
var socket = new Socket(ip.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
|
|
try
|
|
{
|
|
await socket.ConnectAsync(new IPEndPoint(ip, context.DnsEndPoint.Port), cancellationToken);
|
|
return new NetworkStream(socket, ownsSocket: true);
|
|
}
|
|
catch
|
|
{
|
|
socket.Dispose();
|
|
}
|
|
}
|
|
|
|
throw new HttpRequestException("Unable to connect to validated public IP for host.");
|
|
}
|
|
};
|
|
}
|
|
|
|
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 (handler is null)
|
|
{
|
|
if (!await IsSafePublicHostAsync(uri, ct))
|
|
return false;
|
|
}
|
|
else if (IPAddress.TryParse(uri.Host, out var literal) && IsBlockedAddress(literal))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
cts.CancelAfter(TimeSpan.FromSeconds(3));
|
|
|
|
using var fallbackClient = handler is null ? null : new HttpClient(handler, disposeHandler: false);
|
|
var client = fallbackClient ?? httpFactory.CreateClient("imageValidation");
|
|
|
|
try
|
|
{
|
|
using var head = new HttpRequestMessage(HttpMethod.Head, uri);
|
|
var headResp = await client.SendAsync(head, HttpCompletionOption.ResponseHeadersRead, cts.Token);
|
|
if (WasRedirected(uri, headResp))
|
|
return false;
|
|
if (headResp is { IsSuccessStatusCode: true, StatusCode: not System.Net.HttpStatusCode.Redirect })
|
|
{
|
|
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 */
|
|
}
|
|
|
|
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 (WasRedirected(uri, resp))
|
|
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))
|
|
return true;
|
|
|
|
await using var stream = await resp.Content.ReadAsStreamAsync(cts.Token);
|
|
var rented = new byte[12];
|
|
var read = await stream.ReadAsync(rented.AsMemory(0, rented.Length), cts.Token);
|
|
var sig = new ReadOnlySpan<byte>(rented, 0, read);
|
|
|
|
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;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private const long MaxImageBytes = 5 * 1024 * 1024; // 5 MB guard
|
|
|
|
private static bool WasRedirected(Uri requestedUri, HttpResponseMessage response)
|
|
{
|
|
var finalUri = response.RequestMessage?.RequestUri;
|
|
if (finalUri is null)
|
|
return false;
|
|
|
|
return !requestedUri.Equals(finalUri);
|
|
}
|
|
|
|
private static async Task<bool> IsSafePublicHostAsync(Uri uri, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
var addresses = await ResolveSafePublicAddressesAsync(uri.Host, ct);
|
|
return addresses.Count > 0;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static async Task<IReadOnlyList<IPAddress>> ResolveSafePublicAddressesAsync(string host, CancellationToken ct)
|
|
{
|
|
if (!IsSupportedHostType(host))
|
|
return [];
|
|
|
|
IPAddress[] resolved;
|
|
if (IPAddress.TryParse(host, out var literal))
|
|
{
|
|
resolved = [literal];
|
|
}
|
|
else
|
|
{
|
|
resolved = await Dns.GetHostAddressesAsync(host, ct);
|
|
}
|
|
|
|
var safe = new List<IPAddress>(resolved.Length);
|
|
foreach (var ip in resolved)
|
|
{
|
|
if (!IsBlockedAddress(ip))
|
|
safe.Add(ip);
|
|
}
|
|
|
|
return safe.Distinct().ToArray();
|
|
}
|
|
|
|
private static bool IsSupportedHostType(string host)
|
|
{
|
|
var type = Uri.CheckHostName(host);
|
|
return type is UriHostNameType.Dns or UriHostNameType.IPv4 or UriHostNameType.IPv6;
|
|
}
|
|
|
|
private static bool IsBlockedAddress(IPAddress ip)
|
|
{
|
|
if (IPAddress.IsLoopback(ip))
|
|
return true;
|
|
|
|
if (ip.IsIPv4MappedToIPv6)
|
|
return IsBlockedAddress(ip.MapToIPv4());
|
|
|
|
if (ip.AddressFamily == AddressFamily.InterNetwork)
|
|
return IsBlockedIpv4(ip);
|
|
|
|
if (ip.AddressFamily == AddressFamily.InterNetworkV6)
|
|
return IsBlockedIpv6(ip);
|
|
|
|
return true;
|
|
}
|
|
|
|
private static bool IsBlockedIpv4(IPAddress ip)
|
|
{
|
|
var b = ip.GetAddressBytes();
|
|
return b[0] switch
|
|
{
|
|
0 => true, // "This network"
|
|
10 => true, // private
|
|
100 when b[1] >= 64 && b[1] <= 127 => true, // CGNAT
|
|
127 => true, // loopback
|
|
169 when b[1] == 254 => true, // link local
|
|
172 when b[1] >= 16 && b[1] <= 31 => true, // private
|
|
192 when b[1] == 0 && b[2] == 0 => true, // IETF protocol assignments
|
|
192 when b[1] == 0 && b[2] == 2 => true, // documentation
|
|
192 when b[1] == 88 && b[2] == 99 => true, // 6to4 relay anycast
|
|
192 when b[1] == 168 => true, // private
|
|
198 when b[1] is 18 or 19 => true, // benchmarking
|
|
198 when b[1] == 51 && b[2] == 100 => true, // documentation
|
|
203 when b[1] == 0 && b[2] == 113 => true, // documentation
|
|
>= 224 => true, // multicast/reserved/broadcast
|
|
_ => false
|
|
};
|
|
}
|
|
|
|
private static bool IsBlockedIpv6(IPAddress ip)
|
|
{
|
|
if (ip.Equals(IPAddress.IPv6None))
|
|
return true;
|
|
if (ip.IsIPv6Multicast || ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal)
|
|
return true;
|
|
|
|
var bytes = ip.GetAddressBytes();
|
|
if ((bytes[0] & 0xFE) == 0xFC) // fc00::/7 unique local
|
|
return true;
|
|
if (bytes[0] == 0x20 && bytes[1] == 0x01 && bytes[2] == 0x0D && bytes[3] == 0xB8) // 2001:db8::/32 docs
|
|
return true;
|
|
|
|
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 = "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 = "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;
|
|
|
|
return uri.Scheme is "http" or "https";
|
|
}
|
|
|
|
public static async Task<bool> IsAdmin(HttpContext ctx, AppDbContext db)
|
|
{
|
|
var player = await GetAuthenticatedPlayer(ctx, db);
|
|
return player?.IsAdmin == true;
|
|
}
|
|
|
|
public static AppState NewAppState() => new()
|
|
{
|
|
Id = 1,
|
|
ResultsOpen = false,
|
|
UpdatedAt = DateTimeOffset.UnixEpoch
|
|
};
|
|
|
|
public static Dictionary<int, int> BuildLinkRoots(IEnumerable<(int Id, int? ParentId)> items)
|
|
{
|
|
var parentMap = items.ToDictionary(x => x.Id, x => x.ParentId);
|
|
var roots = new Dictionary<int, int>();
|
|
foreach (var id in parentMap.Keys)
|
|
{
|
|
roots[id] = FindRootId(id, parentMap);
|
|
}
|
|
|
|
return roots;
|
|
}
|
|
|
|
public static int FindRootId(int suggestionId, IReadOnlyDictionary<int, int?> parentMap)
|
|
{
|
|
var current = suggestionId;
|
|
var visited = new HashSet<int>();
|
|
|
|
while (parentMap.TryGetValue(current, out var parent) && parent is { } p && !visited.Contains(p))
|
|
{
|
|
visited.Add(current);
|
|
current = p;
|
|
}
|
|
|
|
return current;
|
|
}
|
|
|
|
public static List<int> LinkedIdsFor(int suggestionId, IReadOnlyDictionary<int, int> rootIndex)
|
|
{
|
|
if (!rootIndex.TryGetValue(suggestionId, out var root))
|
|
return [];
|
|
|
|
return rootIndex.Where(kv => kv.Value == root).Select(kv => kv.Key).ToList();
|
|
}
|
|
}
|