Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/MUI.Crawl/ProbeOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,26 @@ public sealed record ProbeOptions
/// What the crawler calls itself over TTYPE/MTTS and MNES <c>CLIENT_NAME</c> (spec §11).
/// </summary>
/// <remarks>
/// <para>
/// An admin reading their logs must be able to find out who we are and how to opt out, so this
/// is a politeness obligation rather than a cosmetic string. It carries a URL for the same
/// reason.
/// </para>
/// <para>
/// <b>It does not reach the wire yet, and neither does <see cref="InfoUrl"/>.</b>
/// TelnetNegotiationCore's <c>TerminalTypeProtocol</c> hardcodes a client's terminal types to
/// <c>TNC</c>, <c>XTERM</c>, <c>MTTS 3853</c> in a private field with no setter, and its
/// <c>NewEnvironProtocol</c> answers a server's NEW-ENVIRON request with the crawler host's own
/// <c>USER</c> and a fixed <c>LANG</c> — so what an admin actually sees is the library's default
/// and a local account name, not us. The library is first-party: the fix is a PR there making
/// both settable, never a reflection hack or a hand-rolled plugin here.
/// </para>
/// <para>
/// Until then nothing may claim otherwise. <c>/about</c> reads this field and says plainly that
/// the crawler is <em>configured</em> to call itself this and does not manage to, because
/// "the crawler identifies itself" is a claim about our own behaviour and that one would be
/// false in exactly the way <c>ContactedMaintainer</c>'s default was.
/// </para>
/// </remarks>
public IReadOnlyList<string> TerminalTypes { get; init; } =
["MUINDEX-CRAWLER", "MUINDEX", "MTTS 9"];
Expand Down Expand Up @@ -159,5 +176,11 @@ public sealed record ProbeOptions
public const byte MsspOption = 70;

/// <summary>Where an admin can read what we do and ask us to stop.</summary>
/// <remarks>
/// A placeholder domain, because the domain is an open question (spec §15.1) and inventing one
/// here would settle it by accident. It is also not yet sent to anybody — see
/// <see cref="TerminalTypes"/> — so a deployment that leaves this alone is publishing an address
/// that answers nobody. <c>/about</c> compares against this default and says so when it matches.
/// </remarks>
public string InfoUrl { get; init; } = "https://muindex.example/crawler";
}
364 changes: 364 additions & 0 deletions src/MUI.Web/Components/AboutPage.cs

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/MUI.Web/Components/Layout/MainLayout.razor
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
<nav aria-label="Catalogues">
<a href="/games">games</a>
<a href="/archive">archive</a>
<a href="/about">about</a>
</nav>
<span class="kicker">every game here was checked by a machine, and every fact says when</span>
</header>
Expand Down
123 changes: 123 additions & 0 deletions src/MUI.Web/Components/Pages/About.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
@page "/about"
@using Microsoft.Extensions.Options
@using MUI.Crawl
@using MUI.Web.Api
@inject IOptions<DatasetLicenceOptions> Dataset
@inject IServiceProvider Services

@*
The page this project owes the sites it read and the servers it dials.

Spec §7.6 and §11 both require it, and neither of them calls it a feature: crediting the
directories a backfill read, publishing who is knocking and how to make them stop, and stating
the limits of what a measurement here proves are obligations already incurred by crawling.

It is prose and a short list, and it is deliberately the plainest page on the site — the whole
argument this project makes is that a fact stated with its provenance beats a fact presented
well, and the page making that argument would be a poor place to contradict it.
*@

@if (Plain)
{
<pre class="mono plain">@PlainText.RenderAbout(Page)</pre>
}
else
{
<PageTitle>About — mu*index</PageTitle>

<h1>About mu*index</h1>
<p class="summary">@Page.Lede</p>

@foreach (var section in Page.Sections)
{
<section class="about" aria-labelledby="about-@section.Id">
<h2 id="about-@section.Id">@section.Heading</h2>

@foreach (var point in section.Points)
{
<p class="note"><strong>@point.Lead</strong> @point.Body</p>
}

@if (section.Identity is { } identity)
{
<p class="note">@identity.Wording</p>

<dl class="identity mono">
<dt class="kicker">crawler</dt>
<dd>@identity.Name</dd>
<dt class="kicker">contact</dt>
<dd>
<a href="@identity.InfoUrl">@identity.InfoUrl</a>
@if (!identity.ContactConfigured)
{
@* Amber is the site's word for declared-but-unverified, and an address
nobody configured is exactly that: a value with nothing behind it. *@
<span class="warn"> — placeholder; this deployment set no contact address</span>
}
</dd>
</dl>
}

@if (section.Sources.Count > 0)
{
<ul class="sources">
@foreach (var source in section.Sources)
{
@* Name and state on one line, the note under both. The listing's two-column
row is wrong here: a note is a paragraph rather than a facts column, and
reserving a column for a short badge leaves the prose reading in a gutter. *@
<li>
<p class="source-head">
<a class="name" href="@source.Url" rel="external">@source.Name</a>
<span class="badge">@source.StatusWording</span>
</p>
<p class="dim">@source.Note</p>
</li>
}
</ul>
}

@if (section.Licence is { } licence)
{
<dl class="identity mono">
<dt class="kicker">code</dt>
<dd>@licence.CodeLicence</dd>
<dt class="kicker">data, as this deployment serves it</dt>
<dd>
@if (licence.DataLicenceUrl is { } url)
{
<a href="@url">@licence.DataLicenceName</a>
}
else
{
@licence.DataLicenceName
}
</dd>
<dt class="kicker">credit as</dt>
<dd>@licence.Attribution</dd>
</dl>

<p class="note dim">@licence.Notice</p>
}
</section>
}

<p class="faint"><a href="?plain=1">read this page as plain text</a></p>
}

@code {
[SupplyParameterFromQuery(Name = "plain")] private string? PlainFlag { get; set; }

private bool Plain => Truthy.Is(PlainFlag);

/*
The crawler's own options when the crawl loop is hosted in this process, and the defaults
when it is not. Asking the container rather than constructing one means the identity this
page publishes is the identity the probe was built from — a page that said MUINDEX-CRAWLER
while a deployment had configured something else would be describing the source tree instead
of the crawler, which is the failure mode of every claim on this site that nobody pinned.
*/
private AboutPage Page => AboutPage.Build(
Services.GetService(typeof(ProbeOptions)) as ProbeOptions ?? new ProbeOptions(),
Dataset.Value);
}
73 changes: 73 additions & 0 deletions src/MUI.Web/Components/PlainText.cs
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,79 @@ public static string RenderArchive(IReadOnlyList<ArchiveEntry> entries, string?
return b.ToString();
}

/// <summary>
/// The about page. Prose, so the only thing the graphical version adds is the shape of it.
/// </summary>
/// <remarks>
/// The attribution list is the part that has to survive here above all: it is what this project
/// owes the directories it read, and an acknowledgement a text browser cannot render is an
/// acknowledgement made to the layout rather than to anybody.
/// </remarks>
public static string RenderAbout(AboutPage page)
{
var b = new StringBuilder();

b.AppendLine("ABOUT MU*INDEX");
b.AppendLine();
Wrap(b, page.Lede);

foreach (var section in page.Sections)
{
Heading(b, section.Heading);

foreach (var point in section.Points)
{
b.AppendLine();
Wrap(b, point.Sentence, " ");
}

if (section.Identity is { } identity)
{
b.AppendLine();
Wrap(b, identity.Wording, " ");
b.AppendLine();
Wrap(b, $"Crawler: {identity.Name}", " ");
Wrap(b, $"Contact: {identity.InfoUrl}", " ");

if (!identity.ContactConfigured)
{
Wrap(b, "This deployment has not set a contact address, so the one above is "
+ "the built-in placeholder and answers nobody.", " ");
}
}

foreach (var source in section.Sources)
{
b.AppendLine();
b.AppendLine($" {source.Name} — {source.StatusWording}");
b.AppendLine($" {source.Url}");
Wrap(b, source.Note, " ");
}

if (section.Licence is { } licence)
{
b.AppendLine();
// Every one of these goes through the wrapper rather than being laid out in columns:
// a licence name and an attribution are both configuration, and a deployment that
// sets a long one must not push a line off the side of a text browser.
Wrap(b, $"Code: {licence.CodeLicence}", " ");
Wrap(b, $"Data: {licence.DataLicenceName}", " ");

if (licence.DataLicenceUrl is { } url)
{
Wrap(b, url, " ");
}

Wrap(b, "(what this deployment serves. The project's own answer is still open.)", " ");
Wrap(b, $"Credit as: {licence.Attribution}", " ");
b.AppendLine();
Wrap(b, licence.Notice, " ");
}
}

return b.ToString();
}

private static void Heading(StringBuilder b, string title)
{
b.AppendLine();
Expand Down
18 changes: 18 additions & 0 deletions src/MUI.Web/wwwroot/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -517,3 +517,21 @@ fieldset.facets .check { display: inline-flex; gap: 6px; align-items: center; fo
border-bottom: 1px solid var(--amber);
color: var(--text);
}

/* ── about ─────────────────────────────────────────────────────────────────
Prose measure and nothing else. The one page whose whole job is to say what the rest of the site
can and cannot prove is the wrong place to spend the accent on decoration, so it carries no
colour of its own — the only tinted thing here is a contact address nobody configured, which is
amber for the same reason every other unverified value on this site is. */

section.about { max-width: 68ch; }
section.about h2 { margin-top: calc(var(--cpad) * 2.5); }

dl.identity { font-size: 13px; margin: var(--cpad) 0; }
dl.identity dt { margin-top: 8px; }
dl.identity dd { margin: 2px 0 0; color: var(--dim); }

ul.sources { list-style: none; margin: var(--cpad) 0 0; padding: 0; }
ul.sources li { border-top: 1px solid var(--line); padding: var(--cpad) 0; }
ul.sources .name { font-size: 15px; font-weight: 600; text-decoration: none; }
ul.sources .source-head { display: flex; gap: var(--cpad); align-items: baseline; flex-wrap: wrap; }
Loading