diff --git a/src/MUI.Web/Api/BadgeEndpoints.cs b/src/MUI.Web/Api/BadgeEndpoints.cs new file mode 100644 index 00000000..17a728ed --- /dev/null +++ b/src/MUI.Web/Api/BadgeEndpoints.cs @@ -0,0 +1,181 @@ +using MUI.Catalog; + +using Microsoft.Net.Http.Headers; + +namespace MUI.Web.Api; + +/// +/// Spec §8.5's owner-published outputs: a live player-count badge, and JSON for the game's own site. +/// +/// +/// +/// Public, not owner-gated. These are meant to be embedded on a page we do not control, by an +/// operator who does not want to proxy them, so they answer anybody — and they publish nothing the +/// game's own page does not already show. What a claim grants is the reason to want them, +/// not permission to fetch them. +/// +/// +/// Both go through , so both get a strong ETag over the exact bytes, +/// If-None-Match handling, nosniff and the open CORS header the rest of §10 has. The +/// one thing they override is the cache window: a badge is refetched by every reader of somebody's +/// front page, and a minute is too little. +/// +/// +public static class BadgeEndpoints +{ + public static void Map(IEndpointRouteBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + + app.MapGet("/g/{slug}/badge.svg", SvgAsync); + app.MapGet("/g/{slug}/badge.json", JsonAsync); + } + + /// The badge, as an image. + public static async Task SvgAsync( + HttpContext http, + string slug, + IGameQueries queries, + ISlugHistory slugs, + TimeProvider clock) + { + var (game, redirected) = await ResolveAsync(http, slug, queries, slugs, ".svg"); + + if (redirected) + { + return; + } + + if (game is null) + { + // A badge rather than an empty 404, because whoever sees this is the operator who just + // pasted the wrong URL onto their own site, and a broken-image icon says nothing. + http.Response.StatusCode = StatusCodes.Status404NotFound; + http.Response.ContentType = "image/svg+xml; charset=utf-8"; + http.Response.Headers[HeaderNames.CacheControl] = "no-store"; + await http.Response.WriteAsync(PlayerBadge.UnknownSvg(), http.RequestAborted); + return; + } + + var reading = PlayerBadge.Read(game, clock.GetUtcNow()); + var body = System.Text.Encoding.UTF8.GetBytes(PlayerBadge.Svg(reading, game.Name)); + + ApiResponse.Prepare(http, "image/svg+xml; charset=utf-8", ETag.Of(body)); + http.Response.Headers[HeaderNames.CacheControl] = PlayerBadge.CacheControl; + + if (ApiResponse.NotModified(http, ETag.Of(body))) + { + return; + } + + http.Response.ContentLength = body.Length; + await http.Response.Body.WriteAsync(body, http.RequestAborted); + } + + /// The same reading, for a page that would rather draw its own. + public static async Task JsonAsync( + HttpContext http, + string slug, + IGameQueries queries, + ISlugHistory slugs, + TimeProvider clock) + { + var (game, redirected) = await ResolveAsync(http, slug, queries, slugs, ".json"); + + if (redirected) + { + return; + } + + if (game is null) + { + await ApiResponse.ProblemAsync( + http, + StatusCodes.Status404NotFound, + "No such game", + $"Nothing in the catalogue answers to '{slug}'."); + return; + } + + var reading = PlayerBadge.Read(game, clock.GetUtcNow()); + + await ApiResponse.WriteJsonAsync(http, new BadgeView( + game.Slug, + game.Name, + game.State, + reading.Count, + reading.Word, + reading.Description, + reading.Age?.TotalSeconds, + + // Null unless we measured it, and gated on the same chip the reading is: a + // measuredAt beside a count of null would be an instant attached to nothing, and one + // beside a game's own MSSP assertion would name a measurement nobody took. + game.PlayersNowProvenance is { IsMeasured: true } measured + ? measured.LastConfirmedAt + : null, + game.LastReachableAt, + ApiRoutes.Page(game.Slug), + $"{ApiRoutes.Page(game.Slug)}/badge.svg")); + + http.Response.Headers[HeaderNames.CacheControl] = PlayerBadge.CacheControl; + } + + /// + /// The game a slug names, redirecting from one it used to have (spec §5.7). + /// + /// + /// A badge is the single most likely thing on this site to outlive the URL it was copied from: + /// it is pasted into somebody's template once and left for years, and §5.7's forever-redirect is + /// the promise that makes that safe. + /// + /// The second half of the answer is not a nicety: this returned a bare null for both "no such + /// game" and "redirected", so the caller wrote a 404 over the 301 it had just set and the + /// forever-redirect worked for no route on this pair. Two outcomes, two values. + /// + private static async Task<(GameSummary? Game, bool Redirected)> ResolveAsync( + HttpContext http, + string slug, + IGameQueries queries, + ISlugHistory slugs, + string suffix) + { + if (await queries.FindAsync(slug, http.RequestAborted) is { } page) + { + return (page.Summary, false); + } + + if (await slugs.CurrentSlugAsync(slug, http.RequestAborted) is { } current + && await queries.FindAsync(current, http.RequestAborted) is not null) + { + http.Response.StatusCode = StatusCodes.Status301MovedPermanently; + http.Response.Headers[HeaderNames.Location] = $"{ApiRoutes.Page(current)}/badge{suffix}"; + http.Response.Headers[HeaderNames.CacheControl] = PlayerBadge.CacheControl; + return (null, true); + } + + return (null, false); + } +} + +/// +/// The badge as data (spec §8.5), for a site that would rather render its own. +/// +/// +/// is null whenever nothing was measured, and says which of +/// the three cases that is — so a consumer coercing null to zero has to do it on purpose, and one +/// reading the state cannot do it at all. Both are published because §5.4's middle case is the one +/// every reimplementation loses. +/// +public sealed record BadgeView( + string Slug, + string Name, + LifecycleState Lifecycle, + int? Count, + string State, + string Description, + double? AgeSeconds, + DateTimeOffset? MeasuredAt, + DateTimeOffset? LastReachableAt, + string PageUrl, + string BadgeUrl); diff --git a/src/MUI.Web/Api/MuiApi.cs b/src/MUI.Web/Api/MuiApi.cs index b727ff2f..286bf406 100644 --- a/src/MUI.Web/Api/MuiApi.cs +++ b/src/MUI.Web/Api/MuiApi.cs @@ -59,6 +59,11 @@ public static IEndpointRouteBuilder MapMuiApi(this IEndpointRouteBuilder endpoin FeedEndpoints.Map(endpoints); DumpEndpoints.Map(endpoints); + // §8.5's owner-published outputs. Off /g/ rather than /api/, because these are pasted into + // somebody else's template by hand and the shortest honest URL is the one that survives + // being retyped. + BadgeEndpoints.Map(endpoints); + return endpoints; } diff --git a/src/MUI.Web/Api/PlayerBadge.cs b/src/MUI.Web/Api/PlayerBadge.cs new file mode 100644 index 00000000..af853352 --- /dev/null +++ b/src/MUI.Web/Api/PlayerBadge.cs @@ -0,0 +1,249 @@ +using System.Globalization; +using System.Net; +using System.Text; + +using MUI.Catalog; + +namespace MUI.Web.Api; + +/// What a badge can be showing. Three states, and never two. +/// +/// The same three-state discipline §5.4 applies to an hour of the heatmap, applied to a number on +/// somebody else's front page. covers a measured zero — we got in and nobody +/// was there, which is a real fact about a game and a filled cell — and covers +/// both "we could not count" and "we have not counted recently". Collapsing the second into a zero +/// is rule 4, broken on a page we do not control and cannot correct. +/// +public enum BadgeState +{ + Counted, + + Unknown, + + /// The game has gone dark (§7.5). A live-count badge has no live count to give. + Archived, +} + +/// +/// The live player-count badge of spec §8.5 — an owner-published output, on somebody else's page. +/// +/// +/// +/// This is the surface with the least room and the most exposure, and the rules do not bend for +/// it. A badge is embedded where we have no other sentence, no footnote and no chance to +/// explain, so the number it shows has to carry its own age and its own state or it is exactly the +/// unlabelled figure the incumbents publish. There is space: "14 now · 4m ago" is nine characters +/// more than "14". +/// +/// +/// An unknown count is never a zero. A game whose WHO we cannot parse renders +/// "players unknown" in grey, not "0 players" in green — the second would be our parser's limit +/// published as a fact about their game, on their own front page, which is rule 5 at its most +/// damaging. +/// +/// +/// No external font, no remote reference, no script. The SVG is self-contained because a badge that +/// fetched anything would put a third-party request on every page that embeds it, and because an +/// <img> does not execute one anyway. +/// +/// +public static class PlayerBadge +{ + /// + /// How long a badge may be cached by a browser or a CDN. + /// + /// + /// Five minutes: short enough that "live" is not a lie on a page somebody refreshes, long enough + /// that a game on a popular front page does not turn its readers into our traffic. Deliberately + /// well inside FieldRegistry.Volatile, the two hours after which a count is stale — a + /// cache entry that outlived the freshness of what it holds would be publishing a stale number + /// with a live label. + /// + public const string CacheControl = "public, max-age=300"; + + /// The site's own accent, which means measured everywhere it appears. + private const string Measured = "#35d29a"; + + /// Grey. Never the accent, because there is nothing measured to point at. + private const string Absent = "#6b747c"; + + private const string Label = "mu*index"; + + private const int Height = 20; + + /// + /// Reads a summary into the one of three things a badge can say. + /// + /// + /// Archived is decided before the count, and not after. An archived game may still carry a count + /// from the last time it answered, and rendering it would put a live-sounding number on a page + /// for a game that stopped answering in 2023. + /// + public static BadgeReading Read(GameSummary game, DateTimeOffset now) + { + ArgumentNullException.ThrowIfNull(game); + + if (game.State is LifecycleState.Archived) + { + return new BadgeReading(BadgeState.Archived, null, null, game.LastReachableAt); + } + + // The two halves of a measurement travel together or not at all: a count with no label is a + // number we cannot age, and a label with no count labels nothing. + // + // AND THE LABEL HAS TO SAY WE MEASURED IT. This badge writes "N players measured 4m ago" and + // paints it in the accent that means measured everywhere on this site, on a page we do not + // control — so a count the game asserted about itself in MSSP may not reach it. Read off the + // same ProvenanceChip.IsMeasured that ApiMapper reads, because the badge and + // /api/games/{slug}'s playersNowState answer the same question about the same game and two + // surfaces disagreeing is the failure the labelling exists to prevent. + // + // Declared therefore renders as unknown, which is the honest thing for a badge that can only + // say three things: we have not counted. What the game says about itself is on its page, + // labelled as theirs, where there is room to attribute it. + return game is { PlayersNow: { } count, PlayersNowProvenance: { IsMeasured: true } chip } + ? new BadgeReading(BadgeState.Counted, count, now - chip.LastConfirmedAt, game.LastReachableAt) + : new BadgeReading(BadgeState.Unknown, null, null, game.LastReachableAt); + } + + /// The badge as an SVG document. + public static string Svg(BadgeReading reading, string gameName) + { + ArgumentNullException.ThrowIfNull(reading); + + var value = reading.Text; + var colour = reading.State is BadgeState.Counted ? Measured : Absent; + + var labelWidth = Width(Label) + 12; + var valueWidth = Width(value) + 12; + var total = labelWidth + valueWidth; + + // Everything interpolated below is either ours or escaped. A game's name is MSSP text and + // therefore attacker-controlled — it reaches the accessible title and nowhere else, and it + // goes through WebUtility.HtmlEncode on the way, because an SVG is a document and a name + // containing "" would otherwise be markup. + var title = WebUtility.HtmlEncode($"{gameName} — {reading.Description}"); + + return $""" + + {title} + + + + + + + + + + {Label} + {WebUtility.HtmlEncode(value)} + + + """; + } + + /// + /// A badge for a slug we do not have, as a badge. + /// + /// + /// A 404 with a body, rather than an empty one. The reader of this is an operator who has just + /// pasted the wrong URL into their own site, and a broken-image icon tells them nothing while + /// this tells them the thing they need to know. The status is still 404 and it is never cached. + /// + public static string UnknownSvg() => + Svg(new BadgeReading(BadgeState.Unknown, null, null, null) { Override = "unknown game" }, "mu*index"); + + /// + /// How wide a string renders at 11px, near enough to lay a box out around it. + /// + /// + /// An estimate, and it only has to be good enough that the text does not touch the edges: SVG + /// has no text metrics without a layout engine, and shipping a font to get them would put a + /// remote asset on every page that embeds this. Digits and lower-case are the common case and + /// are measured closest; anything wider simply gets a roomier box. + /// + private static int Width(string text) + { + var width = 0d; + + foreach (var c in text) + { + width += c switch + { + >= 'A' and <= 'Z' => 7.5, + 'i' or 'j' or 'l' or 't' or 'f' or 'r' or '.' or ' ' or '·' => 3.6, + 'm' or 'w' => 9.5, + _ => 6.3, + }; + } + + return (int)Math.Ceiling(width); + } +} + +/// +/// What the badge says, and why. The same reading serves the SVG and the JSON. +/// +/// +/// One reading behind both outputs, so an owner embedding the image and an owner reading the JSON +/// cannot be told two different things about the same moment — which is the same reason the plain +/// surface renders from the page's own view models. +/// +public sealed record BadgeReading( + BadgeState State, + int? Count, + TimeSpan? Age, + DateTimeOffset? LastReachableAt) +{ + /// Set only by , for a slug that names no game. + internal string? Override { get; init; } + + /// + /// The words on the badge. + /// + /// + /// A measured zero says "0 now", which is a fact we measured and are entitled to publish. An + /// unmeasured count says "players unknown" and never borrows the shape of a number. + /// + public string Text => Override ?? State switch + { + BadgeState.Counted => $"{Count!.Value.ToString(CultureInfo.InvariantCulture)} now · {Relative()}", + BadgeState.Archived => "archived", + _ => "players unknown", + }; + + /// The same, as a sentence, for the accessible title and the JSON. + public string Description => State switch + { + BadgeState.Counted => $"{Count} players measured {Relative()} ago", + BadgeState.Archived => "archived — this game has stopped answering", + _ => "player count not measured", + }; + + /// How the API names this state, matching where it can. + public string Word => State switch + { + BadgeState.Counted => "measured", + BadgeState.Archived => "archived", + _ => "unknown", + }; + + /// + /// A coarse age, because a badge has room for two characters and not for "4 minutes ago". + /// + /// + /// Rounded down, never up. "1h" for something measured fifty-nine minutes ago overstates its + /// age, which is the safe direction; rounding the other way would call an hour-old number fresh. + /// + private string Relative() => Age switch + { + null => "?", + { TotalMinutes: < 1 } => "just now", + { TotalHours: < 1 } age => $"{(int)age.TotalMinutes}m", + { TotalDays: < 1 } age => $"{(int)age.TotalHours}h", + var age => $"{(int)age!.Value.TotalDays}d", + }; +} diff --git a/src/MUI.Web/Components/Pages/Account.razor b/src/MUI.Web/Components/Pages/Account.razor index 1ed7e9bb..bf204cd6 100644 --- a/src/MUI.Web/Components/Pages/Account.razor +++ b/src/MUI.Web/Components/Pages/Account.razor @@ -95,6 +95,26 @@ else Name="game.Name" Declared="DeclaredFor(game.Id)" Now="Now" /> + + @* + §8.5's owner-published outputs. Shown here because this is where the person + who would paste it is, and shown as the exact line to copy — the same reason + the claim page prints the MSSP variable in full. In the block's body rather + than its summary: a
inside a is interactive content + nested in a control, which no browser owes anybody a sane answer for. + *@ +
+ put your player count on your own site +
<a href="/g/@game.Slug"><img src="/g/@game.Slug/badge.svg" alt="@game.Name on mu*index"></a>
+

+ The badge carries the count and when we measured it, because a + number with no age is the thing this site exists to replace. It says + players unknown rather than nought when we could not count, and + archived if the game stops answering. There is + JSON too, if you would rather draw + your own. +

+
} } diff --git a/src/MUI.Web/wwwroot/app.css b/src/MUI.Web/wwwroot/app.css index 17b42196..9398f4a1 100644 --- a/src/MUI.Web/wwwroot/app.css +++ b/src/MUI.Web/wwwroot/app.css @@ -1100,3 +1100,20 @@ p.refused { border-left: 2px solid var(--amber); padding-left: 10px; } dl.lint dt { font-size: 13px; margin-top: var(--cpad); } dl.lint dd { margin: 2px 0 0; color: var(--dim); } dl.lint dd .kicker { display: block; margin-bottom: 2px; } + +/* ── owner-published outputs ─────────────────────────────────────────────── + §8.5's badge snippet, shown where the person who pastes it already is. The + pre wraps rather than scrolls: a line somebody has to copy should be visible + in one piece. */ + +details.publish { margin-top: 6px; } +details.publish > summary { cursor: pointer; font-size: 12px; } +details.publish pre { + background: var(--recessed); + border: 1px solid var(--line); + border-radius: 3px; + padding: 8px 10px; + font-size: 12px; + white-space: pre-wrap; + word-break: break-all; +} diff --git a/tests/MUI.Web.Tests/Api/BadgeApiTests.cs b/tests/MUI.Web.Tests/Api/BadgeApiTests.cs new file mode 100644 index 00000000..7d6eae17 --- /dev/null +++ b/tests/MUI.Web.Tests/Api/BadgeApiTests.cs @@ -0,0 +1,308 @@ +using System.Net; +using System.Text.Json; + +using MUI.Web.Api; + +namespace MUI.Web.Tests.Api; + +/// +/// The owner-published badge (spec §8.5), on a page we do not control. +/// +/// +/// Every rule this site has, asserted at its hardest point. A badge is embedded where there is no +/// footnote, no provenance chip and no second sentence — so if a number can be published unlabelled +/// or a silence can be published as a zero, it happens here first and we never see it. +/// +public class BadgeApiTests +{ + /// + /// The count carries its own age, because there is nowhere else to put one. + /// + /// + /// "15" on somebody's front page is the incumbents' badge. "15 now · 4m ago" is this site's + /// claim, in nine more characters. + /// + [Test] + public async Task AMeasuredCountIsPublishedWithItsAge() + { + await using var host = await ApiHost.StartAsync(); + + var svg = await host.Client.GetStringAsync("/g/m-u-s-h/badge.svg"); + + await Assert.That(svg).Contains("15 now"); + await Assert.That(svg).Contains("4m"); + await Assert.That(svg).Contains("mu*index"); + } + + /// + /// A count the game asserted about itself never goes out as a measurement of ours. + /// + /// + /// + /// Ashen Court publishes PLAYERS in MSSP and answers no pre-login WHO, which is + /// the commonest way a directory ends up quoting a game's own number as though it had counted + /// it. This badge writes "N players measured 4m ago" and paints it in the accent that means + /// measured on every other surface here — on a page we do not control and cannot correct — so + /// the one thing it must never carry is somebody else's assertion. + /// + /// + /// The badge and /api/games/{slug} are held to the same predicate rather than to two + /// judgements that agree today: the JSON below says unknown where the API says + /// declared, and both reach FieldSources.IsMeasured through + /// ProvenanceChip. Two controls, one on each side of that line — M*U*S*H's WHO + /// count, and Aardwolf's, which exists only because we read it off the connect screen. The + /// second is the whole point of the line being drawn on the source and not on who + /// authored the number: we open the socket and parse that text ourselves on every probe, so + /// its freshness is ours, and a badge that refused to show it would be withholding a + /// measurement we took. + /// + /// + [Test] + public async Task ADeclaredCountIsNotPublishedAsAMeasuredOne() + { + await using var host = await ApiHost.StartAsync(); + + var declared = await host.Client.GetStringAsync("/g/ashen-court/badge.svg"); + var json = await Json.ElementAsync(await host.Client.GetAsync("/g/ashen-court/badge.json")); + var measured = await host.Client.GetStringAsync("/g/m-u-s-h/badge.svg"); + var banner = await host.Client.GetStringAsync("/g/aardwolf/badge.svg"); + + await Assert.That(declared).Contains("players unknown"); + await Assert.That(declared).DoesNotContain("9 now") + .Because("nine is what the game says about itself, not what we counted"); + + await Assert.That(json.GetProperty("state").GetString()).IsEqualTo("unknown"); + await Assert.That(json.GetProperty("count").ValueKind).IsEqualTo(JsonValueKind.Null); + await Assert.That(json.GetProperty("measuredAt").ValueKind).IsEqualTo(JsonValueKind.Null) + .Because("an instant beside no count of ours would name a measurement nobody took"); + + await Assert.That(measured).Contains("15 now"); + + await Assert.That(banner).Contains("219 now") + .Because("we read that number off the connect screen ourselves, which is a measurement"); + } + + /// + /// A measured zero is a measurement and says so; an unmeasured count never borrows its shape. + /// + /// + /// This is rule 4 at the point it would do the most damage. Eldertale was probed and nobody was + /// there — a real fact, published. Midnight Sun answered and could not be counted, and renders + /// as unknown; a "0" there would be our parser's limit printed as a fact about their game, on + /// their own website, where we could not correct it. + /// + [Test] + public async Task AMeasuredZeroIsAZeroAndAnUnknownCountIsNever() + { + await using var host = await ApiHost.StartAsync(); + + var zero = await host.Client.GetStringAsync("/g/eldertale/badge.svg"); + var unknown = await host.Client.GetStringAsync("/g/midnight-sun/badge.svg"); + + await Assert.That(zero).Contains("0 now"); + + await Assert.That(unknown).Contains("players unknown"); + await Assert.That(unknown).DoesNotContain("0 now"); + await Assert.That(unknown).DoesNotContain(">0<"); + } + + /// + /// Unknown is grey. The accent means measured everywhere on this site, and a badge is no place + /// to start spending it on something we did not measure. + /// + [Test] + public async Task OnlyAMeasuredBadgeWearsTheMeasuredColour() + { + await using var host = await ApiHost.StartAsync(); + + var measured = await host.Client.GetStringAsync("/g/m-u-s-h/badge.svg"); + var unknown = await host.Client.GetStringAsync("/g/midnight-sun/badge.svg"); + + await Assert.That(measured).Contains("#35d29a"); + await Assert.That(unknown).DoesNotContain("#35d29a"); + } + + /// + /// An archived game's badge says archived rather than showing the last number it had. + /// + /// + /// §7.5: archiving takes a game out of the listing and out of nothing else, so the badge still + /// answers — but a live-count badge for a game that stopped answering in 2023 has no live count, + /// and a stale one under a live label is the one thing it must not print. + /// + [Test] + public async Task AnArchivedGameGetsABadgeThatSaysSo() + { + await using var host = await ApiHost.StartAsync(); + + var svg = await host.Client.GetStringAsync("/g/gaslight-row/badge.svg"); + + await Assert.That(svg).Contains("archived"); + await Assert.That(svg).DoesNotContain("now ·"); + } + + /// + /// A game's name is MSSP text, and MSSP text is attacker-controlled. + /// + /// + /// The name reaches the accessible title and nowhere else, escaped. An SVG is a document, so a + /// game named </text><script> would otherwise be markup on every page that + /// embeds the badge — a stored injection with a distribution mechanism. + /// + [Test] + public async Task AGamesOwnNameCannotBecomeMarkup() + { + var reading = new BadgeReading(BadgeState.Unknown, null, null, null); + + var svg = PlayerBadge.Svg(reading, ""); + + await Assert.That(svg).DoesNotContain("