using System.Text.Json; using FozFc.Api.Application.Zerozero; using FozFc.Api.Infrastructure.Zerozero; namespace FozFc.Api.Api.Endpoints; /// /// Football data feeds scraped from zerozero.pt (replaces the Cloudflare worker): /// matches, news, standings, cup progress, plus an image proxy. All public. /// public static class ZerozeroEndpoints { private static readonly JsonSerializerOptions JsonOptions = new() { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, }; public static IEndpointRouteBuilder MapZerozeroEndpoints(this IEndpointRouteBuilder app) { app.MapGet("/api/matches-feed", (HttpContext ctx, IZerozeroFeedService svc, CancellationToken ct) => Feed(ctx, ct, () => svc.GetMatchesAsync(ct))); app.MapGet("/api/news-feed", (HttpContext ctx, IZerozeroFeedService svc, CancellationToken ct) => Feed(ctx, ct, () => svc.GetNewsAsync(ctx.Request.Query["slug"], ct))); app.MapGet("/api/standings-liga", (HttpContext ctx, IZerozeroFeedService svc, CancellationToken ct) => Feed(ctx, ct, () => svc.GetStandingsLigaAsync(ct))); app.MapGet("/api/standings-taca", (HttpContext ctx, IZerozeroFeedService svc, CancellationToken ct) => Feed(ctx, ct, () => svc.GetStandingsTacaAsync(ct))); app.MapGet("/api/cup-progress", (HttpContext ctx, IZerozeroFeedService svc, CancellationToken ct) => Feed(ctx, ct, () => svc.GetCupProgressAsync(ct))); // Image proxy: /api/media/r/ app.MapGet("/api/media/r/{encoded}", async (string encoded, HttpContext ctx, ZerozeroHttpClient http, CancellationToken ct) => { string target; try { target = ZerozeroImages.Base64UrlDecode(encoded); var host = new Uri(target).Host.ToLowerInvariant(); if (host != "zerozero.pt" && !host.EndsWith(".zerozero.pt")) return Results.Text("Forbidden", "text/plain", statusCode: 403); } catch { return Results.Text("Invalid", "text/plain", statusCode: 400); } return await ProxyMedia(ctx, http, target, ct); }); // Image proxy: /api/media/ -> zerozero.pt/ app.MapGet("/api/media/{**path}", (string path, HttpContext ctx, ZerozeroHttpClient http, CancellationToken ct) => { var target = $"{ZerozeroSite.Origin}/{path}{ctx.Request.QueryString.Value}"; return ProxyMedia(ctx, http, target, ct); }); return app; } private static async Task Feed(HttpContext ctx, CancellationToken ct, Func>> fetch) { try { var payload = await fetch(); ctx.Response.Headers.CacheControl = "public, max-age=86400, stale-while-revalidate=900"; return Results.Text(JsonSerializer.Serialize(payload, JsonOptions), "application/json; charset=utf-8"); } catch (OperationCanceledException) { return Results.StatusCode(499); } catch { // Frontend falls back to demo data on non-2xx. return Results.Json(new { error = "Upstream error" }, statusCode: 502); } } private static async Task ProxyMedia(HttpContext ctx, ZerozeroHttpClient http, string target, CancellationToken ct) { try { var (bytes, contentType) = await http.FetchBytesAsync(target, ct: ct); ctx.Response.Headers.CacheControl = "public, max-age=86400"; return Results.Bytes(bytes, string.IsNullOrEmpty(contentType) ? "application/octet-stream" : contentType); } catch { return Results.StatusCode(502); } } }