Bifrost/src/Bifrost.Web/Features/ConceptVotes/ConceptTallyExport.cs

153 lines
6.9 KiB
C#

using Bifrost.Web.Configuration;
using Microsoft.AspNetCore.Cors.Infrastructure;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
namespace Bifrost.Web.Features.ConceptVotes;
// The public tally export and the one cross-origin policy in Bifrost. It owns nothing: counts are derived by
// ConceptAppraisalService, origins come from ConceptVoteOptions.
public static class ConceptTallyExport
{
public const string CorsPolicy = "ConceptTallies";
private const string RoutePrefix = "/public/concepts";
// GET only, no credentials, and required by the export route alone: every other route carries no CORS
// metadata and gets no CORS headers.
public static IServiceCollection AddConceptTallyCors(this IServiceCollection services)
{
services.AddCors();
services
.AddOptions<CorsOptions>()
.Configure<IOptions<ConceptVoteOptions>>((cors, conceptVotes) =>
cors.AddPolicy(
CorsPolicy,
policy => policy.WithOrigins(conceptVotes.Value.AllowedOrigins).WithMethods("GET")));
return services;
}
// Replaces app.UseCors(): the one place the origin decision is made. Every response under the export route
// (200, 404, 304, preflight, refusal) varies by Origin, appended to any Vary already present, because the CORS
// headers differ by request Origin and a URL-keyed cache must not serve one variant to another. ASP.NET Core
// adds Vary: Origin itself only for policies of more than one origin; this policy has one, and a repeated token is harmless.
public static IApplicationBuilder UseConceptTallyCors(this IApplicationBuilder app)
{
app.Use((context, next) =>
{
if (context.Request.Path.StartsWithSegments(RoutePrefix))
{
context.Response.OnStarting(() =>
{
context.Response.Headers.Append(HeaderNames.Vary, "Origin");
return Task.CompletedTask;
});
}
return next(context);
});
return app.UseCors();
}
// Counts only, per live subject: ConceptTally carries no voter key, account id or network tag.
public static IEndpointConventionBuilder MapConceptTallyExport(this IEndpointRouteBuilder app) =>
app.MapGet(RoutePrefix + "/{collection}/tallies", async (
string collection,
ConceptAppraisalService appraisals,
HttpResponse response,
CancellationToken cancellationToken) =>
{
var tallies = await appraisals.GetTalliesAsync(collection, cancellationToken);
if (tallies.Count == 0)
{
return Results.NotFound();
}
response.Headers.CacheControl = "public, max-age=60";
return Results.Json(tallies);
}).AllowAnonymous().RequireCors(CorsPolicy);
public const int DefaultPageSize = 50;
public const int MaxPageSize = 100;
// The ranked, paged read beside the map: GET /public/concepts/{collection}/ranked
// ?sort=net|total|newest (default net) &limit=1..100 (default 50) &cursor=<opaque> &unvoted=network
// returns { items: [{ key, title, up, down, members: { up, down } }], next_cursor }. The query is a closed
// shape: an unknown or repeated key, an out-of-range value or a cursor that is not exactly one this route issued
// for the same sort and filter is a 400. unvoted=network leaves out the concepts the caller's own network tag
// has already voted on; the tag is derived from the connection and compared inside the query, never accepted
// from the request and never returned. That answer depends on the caller, so it is never cached.
public static IEndpointConventionBuilder MapConceptRankedExport(this IEndpointRouteBuilder app) =>
app.MapGet(RoutePrefix + "/{collection}/ranked", async (
string collection,
HttpContext http,
ConceptAppraisalService appraisals,
CancellationToken cancellationToken) =>
{
if (!ConceptKey.IsValid(collection))
{
return Results.NotFound();
}
if (!TryReadRankedQuery(http.Request.Query, out var order, out var limit, out var cursor, out var unvoted))
{
return JsonText(new { error = "request_invalid" }, StatusCodes.Status400BadRequest);
}
var page = await appraisals.GetRankedAsync(
collection, order, limit, cursor, unvoted, http.Connection.RemoteIpAddress, cancellationToken);
if (page is null)
{
return Results.NotFound();
}
http.Response.Headers.CacheControl = unvoted ? "private, no-store" : "public, max-age=60";
return JsonText(new
{
items = page.Items.Select(i => new
{
key = i.Key,
title = i.Title,
up = i.Tally.Up,
down = i.Tally.Down,
members = new { up = i.Tally.Members.Up, down = i.Tally.Members.Down }
}),
next_cursor = page.NextCursor
}, StatusCodes.Status200OK);
}).AllowAnonymous().RequireCors(CorsPolicy);
private static IResult JsonText(object body, int status) =>
Results.Text(System.Text.Json.JsonSerializer.Serialize(body), "application/json", statusCode: status);
internal static bool TryReadRankedQuery(
IQueryCollection query, out RankOrder order, out int limit, out RankCursor? cursor, out bool unvoted)
{
order = RankOrder.Net;
limit = DefaultPageSize;
cursor = null;
unvoted = false;
foreach (var (key, values) in query)
{
if (values.Count != 1 || values[0] is not { } value)
{
return false;
}
switch (key)
{
case "sort" when value == "net": order = RankOrder.Net; break;
case "sort" when value == "total": order = RankOrder.Total; break;
case "sort" when value == "newest": order = RankOrder.Newest; break;
case "limit" when int.TryParse(value, System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture, out var n) &&
n is >= 1 and <= MaxPageSize && value == n.ToString(System.Globalization.CultureInfo.InvariantCulture):
limit = n;
break;
case "cursor" when RankCursor.TryDecode(value, out var decoded): cursor = decoded; break;
case "unvoted" when value == "network": unvoted = true; break;
default: return false;
}
}
return cursor is null || (cursor.Order == order && cursor.Unvoted == unvoted);
}
}