diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a7a75c..6e8539d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Add `PUBLIC_ADDRESS_DISTRIBUTION_POLICY` with `explicit_public_only` and `public_and_unlisted` values, fatal validation for unknown values, and effective-policy startup reporting. +- Expose the effective public-address distribution policy and human-readable label through `/status.json` schema version 5, and display them in the default generated website footer on every page. ### Changed diff --git a/api/web.go b/api/web.go index 4a0c72e..dd419e4 100644 --- a/api/web.go +++ b/api/web.go @@ -56,18 +56,20 @@ type relayStatusPublishers struct { } type relayStatusResponse struct { - SchemaVersion int `json:"schema_version"` - Status string `json:"status"` - Name string `json:"name"` - Domain string `json:"domain"` - Registration string `json:"registration"` - ManualApproval bool `json:"manual_approval"` - PersonOnly bool `json:"person_only"` - Endpoints relayStatusEndpoints `json:"endpoints"` - ConnectedInstances relayStatusInstances `json:"connected_instances"` - ReceivingInstances relayStatusReceivingInstances `json:"receiving_instances"` - Publishers relayStatusPublishers `json:"publishers"` - Software relayStatusSoftware `json:"software"` + SchemaVersion int `json:"schema_version"` + Status string `json:"status"` + Name string `json:"name"` + Domain string `json:"domain"` + Registration string `json:"registration"` + ManualApproval bool `json:"manual_approval"` + PersonOnly bool `json:"person_only"` + PublicAddressDistributionPolicy models.PublicAddressDistributionPolicy `json:"public_address_distribution_policy"` + PublicAddressDistributionLabel string `json:"public_address_distribution_label"` + Endpoints relayStatusEndpoints `json:"endpoints"` + ConnectedInstances relayStatusInstances `json:"connected_instances"` + ReceivingInstances relayStatusReceivingInstances `json:"receiving_instances"` + Publishers relayStatusPublishers `json:"publishers"` + Software relayStatusSoftware `json:"software"` } func normalizedStatusDomain(domain string) string { @@ -87,9 +89,11 @@ func buildRelayStatus() relayStatusResponse { snapshot := RelayState.Snapshot() baseURL := "" name := RelayActor.Name + publicAddressPolicy := models.PublicAddressPublicAndUnlisted if GlobalConfig != nil { baseURL = strings.TrimRight(GlobalConfig.ServerHostname().String(), "/") + publicAddressPolicy = GlobalConfig.PublicAddressDistributionPolicy() if name == "" { name = GlobalConfig.ServerServiceName() } @@ -173,13 +177,15 @@ func buildRelayStatus() relayStatusResponse { } return relayStatusResponse{ - SchemaVersion: 4, - Status: "ok", - Name: name, - Domain: strings.TrimPrefix(baseURL, "https://"), - Registration: registration, - ManualApproval: snapshot.RelayConfig.ManuallyAccept, - PersonOnly: snapshot.RelayConfig.PersonOnly, + SchemaVersion: 5, + Status: "ok", + Name: name, + Domain: strings.TrimPrefix(baseURL, "https://"), + Registration: registration, + ManualApproval: snapshot.RelayConfig.ManuallyAccept, + PersonOnly: snapshot.RelayConfig.PersonOnly, + PublicAddressDistributionPolicy: publicAddressPolicy, + PublicAddressDistributionLabel: publicAddressPolicy.DisplayName(), Endpoints: relayStatusEndpoints{ Inbox: baseURL + "/inbox", Actor: baseURL + "/actor", diff --git a/api/web_test.go b/api/web_test.go index 47d6cc7..4000fa3 100644 --- a/api/web_test.go +++ b/api/web_test.go @@ -77,8 +77,26 @@ func TestHandleRelayStatus(t *testing.T) { if got.Registration != "open" || got.ManualApproval { t.Errorf("registration = %q, manual_approval = %v", got.Registration, got.ManualApproval) } - if got.SchemaVersion != 4 { - t.Errorf("schema version = %d; want 4", got.SchemaVersion) + wantPublicAddressPolicy := models.PublicAddressPublicAndUnlisted + if GlobalConfig != nil { + wantPublicAddressPolicy = GlobalConfig.PublicAddressDistributionPolicy() + } + if got.PublicAddressDistributionPolicy != wantPublicAddressPolicy { + t.Errorf( + "public-address policy = %q; want %q", + got.PublicAddressDistributionPolicy, + wantPublicAddressPolicy, + ) + } + if got.PublicAddressDistributionLabel != wantPublicAddressPolicy.DisplayName() { + t.Errorf( + "public-address label = %q; want %q", + got.PublicAddressDistributionLabel, + wantPublicAddressPolicy.DisplayName(), + ) + } + if got.SchemaVersion != 5 { + t.Errorf("schema version = %d; want 5", got.SchemaVersion) } wantParticipating := []string{"a.example", "publisher.example", "z.example"} diff --git a/contrib/web/README.md b/contrib/web/README.md index 91af130..f6dff86 100644 --- a/contrib/web/README.md +++ b/contrib/web/README.md @@ -7,6 +7,7 @@ The public website and the relay runtime are separate: - The Go relay exposes ActivityPub endpoints and `/status.json`. - `build-site.py` generates static HTML, CSS, and JavaScript. +- The default generated footer reads the configured `status_url` at runtime and displays the relay's effective public-address distribution policy on every page. - Nginx, Apache, Caddy, or another reverse proxy serves the chosen frontend and proxies the relay endpoints to the Go service. @@ -65,7 +66,11 @@ location / { Point the web server document root at the custom generated or hand-written files. A custom page may read `/status.json` from the same hostname without CORS -configuration. +configuration. Schema version 5 includes `public_address_distribution_policy` +and `public_address_distribution_label`. The default footer uses the raw policy +to select its explanation and the server-provided label for display. When the +status endpoint is unavailable or an older schema omits the fields, it reports +that state rather than displaying a static policy. A minimal CSP-compatible example is provided in: diff --git a/contrib/web/assets/relay.css b/contrib/web/assets/relay.css index 7ee5159..8dd6eb9 100644 --- a/contrib/web/assets/relay.css +++ b/contrib/web/assets/relay.css @@ -62,6 +62,9 @@ input[type="search"] { min-width: 16rem; padding: 0.65rem 0.75rem; border: 1px s .publisher-meta { color: var(--muted); font-size: 0.9rem; } .prose { max-width: 52rem; } .site-footer { padding: 2rem 0 3rem; border-top: 1px solid var(--border); } +.relay-policy-status { margin-bottom: 1.25rem; padding: 1rem; border: 1px solid var(--border); border-radius: 0.75rem; background: var(--surface); } +.relay-policy-status p { margin: 0; } +.relay-policy-status .relay-policy-description { margin-top: 0.35rem; max-width: 52rem; } .status-good { color: var(--success); } .status-warning { color: var(--warning); } diff --git a/contrib/web/assets/relay.js b/contrib/web/assets/relay.js index f848ca4..63f20b3 100644 --- a/contrib/web/assets/relay.js +++ b/contrib/web/assets/relay.js @@ -2,10 +2,13 @@ "use strict"; const dashboard = document.querySelector("[data-relay-dashboard]"); - if (!dashboard) return; + const policyStatus = document.querySelector("[data-relay-policy-status]"); + if (!dashboard && !policyStatus) return; const byId = (id) => document.getElementById(id); - const statusURL = dashboard.dataset.statusUrl || "/status.json"; + const statusURL = dashboard?.dataset.statusUrl + || policyStatus?.dataset.statusUrl + || "/status.json"; const health = byId("relay-health"); const registration = byId("relay-registration"); const count = byId("relay-instance-count"); @@ -21,7 +24,18 @@ const publisherList = byId("publisher-list"); const publisherEmpty = byId("publisher-empty"); const publisherSearch = byId("publisher-search"); - + const policyLabel = byId("relay-policy-label"); + const policyDescription = byId("relay-policy-description"); + const policyPresentations = Object.freeze({ + explicit_public_only: { + label: "Explicitly public posts only", + description: "This relay redistributes posts only when ActivityStreams Public appears in the primary “to” audience. Posts with Public only in “cc” are received but not redistributed." + }, + public_and_unlisted: { + label: "Public and unlisted posts", + description: "This relay redistributes posts when ActivityStreams Public appears in either the primary “to” audience or the secondary “cc” audience." + } + }); let domains = []; let publishers = []; @@ -41,6 +55,52 @@ message.hidden = hidden; } + function renderPolicy(data) { + if (!policyStatus) return; + + const policy = String( + data.public_address_distribution_policy ?? "" + ).trim(); + const presentation = policyPresentations[policy]; + const reportedLabel = String( + data.public_address_distribution_label ?? "" + ).trim(); + + if (!policy) { + setText(policyLabel, "Not reported"); + setText( + policyDescription, + "This relay version does not report its effective public-post distribution policy." + ); + policyStatus.dataset.policy = "not-reported"; + return; + } + + if (!presentation) { + setText(policyLabel, reportedLabel || "Unknown"); + setText( + policyDescription, + "This relay reported an unrecognized public-post distribution policy." + ); + policyStatus.dataset.policy = "unknown"; + return; + } + + setText(policyLabel, reportedLabel || presentation.label); + setText(policyDescription, presentation.description); + policyStatus.dataset.policy = policy; + } + + function renderPolicyUnavailable() { + if (!policyStatus) return; + setText(policyLabel, "Status unavailable"); + setText( + policyDescription, + "The relay's current public-post distribution policy could not be loaded." + ); + policyStatus.dataset.policy = "unavailable"; + } + function renderDomains(filter = "") { if (!list) return; const needle = filter.trim().toLowerCase(); @@ -73,7 +133,6 @@ String(publisher.domain ?? "").toLowerCase().includes(needle) ); publisherList.replaceChildren(); - for (const publisher of visible) { const item = document.createElement("li"); const heading = document.createElement("strong"); @@ -89,7 +148,6 @@ item.append(heading, meta); publisherList.appendChild(item); } - if (publisherEmpty) publisherEmpty.hidden = visible.length !== 0; } @@ -144,7 +202,6 @@ ); setText(inbox, data.endpoints?.inbox ?? "/inbox"); setText(actor, data.endpoints?.actor ?? "/actor"); - domains = Array.isArray(data.connected_instances?.domains) ? data.connected_instances.domains.map( (domain) => String(domain).toLowerCase() @@ -153,9 +210,9 @@ publishers = Array.isArray(data.publishers?.entries) ? data.publishers.entries : []; - renderDomains(); renderPublishers(); + renderPolicy(data); setStatusMessage("", true); }) .catch((error) => { @@ -166,14 +223,13 @@ setText(receivingCount, "—"); setText(publisherCount, "—"); setText(version, "—"); - if (list) { list.innerHTML = '