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 = '
  • The live participating-server list is temporarily unavailable.
  • '; } if (publisherList) { publisherList.innerHTML = '
  • The live publisher list is temporarily unavailable.
  • '; } - + renderPolicyUnavailable(); setStatusMessage( `Unable to load relay status: ${error.message}`, false diff --git a/contrib/web/content/footer.html b/contrib/web/content/footer.html index 5724f03..15649c4 100644 --- a/contrib/web/content/footer.html +++ b/contrib/web/content/footer.html @@ -1,4 +1,11 @@ diff --git a/contrib/web/test_build_site.py b/contrib/web/test_build_site.py index 5952b0d..d7c3180 100644 --- a/contrib/web/test_build_site.py +++ b/contrib/web/test_build_site.py @@ -59,6 +59,13 @@ def test_dashboard_bundle_and_activitypub_contact(self) -> None: self.assertIn("Participating servers", index) self.assertIn('id="relay-receiving-count"', index) + self.assertIn("data-relay-policy-status", index) + self.assertIn('id="relay-policy-label"', index) + self.assertIn('id="relay-policy-description"', index) + self.assertGreaterEqual( + index.count('data-status-url="/status.json"'), + 2, + ) self.assertRegex(index, r"/assets/relay\.css\?v=[0-9a-f]{16}") self.assertRegex(index, r"/assets/relay\.js\?v=[0-9a-f]{16}") self.assertIn( @@ -70,6 +77,13 @@ def test_dashboard_bundle_and_activitypub_contact(self) -> None: index, ) self.assertIn("receiving_instances", javascript) + self.assertIn("public_address_distribution_policy", javascript) + self.assertIn("public_address_distribution_label", javascript) + self.assertIn("explicit_public_only", javascript) + self.assertIn("public_and_unlisted", javascript) + self.assertIn("if (!dashboard && !policyStatus) return;", javascript) + self.assertIn("renderPolicy(data);", javascript) + self.assertIn("renderPolicyUnavailable();", javascript) self.assertIn("if (!publisherList) return;", javascript) self.assertIn('setStatusMessage("", true);', javascript) self.assertIn( @@ -81,6 +95,15 @@ def test_dashboard_bundle_and_activitypub_contact(self) -> None: javascript, ) + def test_footer_uses_configured_status_url(self) -> None: + index, _ = self.build_site( + {"status_url": "/alternate-status.json"} + ) + self.assertGreaterEqual( + index.count('data-status-url="/alternate-status.json"'), + 2, + ) + def test_activitypub_contact_is_optional(self) -> None: index, _ = self.build_site({}) self.assertNotIn("ActivityPub contact:", index) diff --git a/models/public_address_policy.go b/models/public_address_policy.go index 865921f..66d7dda 100644 --- a/models/public_address_policy.go +++ b/models/public_address_policy.go @@ -49,6 +49,18 @@ func (policy PublicAddressDistributionPolicy) String() string { return string(policy) } +// DisplayName returns a stable human-readable public label for the policy. +func (policy PublicAddressDistributionPolicy) DisplayName() string { + switch policy { + case PublicAddressExplicitPublicOnly: + return "Explicitly public posts only" + case PublicAddressPublicAndUnlisted: + return "Public and unlisted posts" + default: + return "Unknown" + } +} + // Allows reports whether the activity enters public-address fan-out. func (policy PublicAddressDistributionPolicy) Allows( to []string, diff --git a/models/public_address_policy_test.go b/models/public_address_policy_test.go index 78562d4..8aa1cfa 100644 --- a/models/public_address_policy_test.go +++ b/models/public_address_policy_test.go @@ -51,6 +51,38 @@ func TestParsePublicAddressDistributionPolicyRejectsUnknown(t *testing.T) { } } +func TestPublicAddressDistributionPolicyDisplayName(t *testing.T) { + tests := []struct { + name string + policy PublicAddressDistributionPolicy + want string + }{ + { + name: "explicit public only", + policy: PublicAddressExplicitPublicOnly, + want: "Explicitly public posts only", + }, + { + name: "public and unlisted", + policy: PublicAddressPublicAndUnlisted, + want: "Public and unlisted posts", + }, + { + name: "unknown fails closed", + policy: PublicAddressDistributionPolicy("future-policy"), + want: "Unknown", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := test.policy.DisplayName(); got != test.want { + t.Fatalf("DisplayName() = %q; want %q", got, test.want) + } + }) + } +} + func TestPublicAddressDistributionPolicyAllows(t *testing.T) { public := activityStreamsPublicAddress followers := "https://publisher.example/actor/followers" diff --git a/readme.md b/readme.md index 51c40bd..ad4b039 100644 --- a/readme.md +++ b/readme.md @@ -27,7 +27,8 @@ Compared with the upstream baseline, this fork includes: publishers, including WordPress ActivityPub sites. - Publisher first-seen, last-seen, activity-type, and accepted-activity counters. -- `/status.json` schema version 4 with: +- `/status.json` schema version 5 with: + - `public_address_distribution_policy` and its human-readable label; - `connected_instances`: all unique participating domains; - `receiving_instances`: domains that receive relay fan-out, with current delivery-health timestamps and counters; @@ -558,7 +559,8 @@ The API server exposes: GET /status.json ``` -Schema version 4 reports relay identity and policy, endpoints, software version, +Schema version 5 reports relay identity and policy, endpoints, software version, +the effective `public_address_distribution_policy`, its human-readable label, and three related domain views: - `connected_instances`: the deduplicated set of domains that receive relay @@ -585,6 +587,8 @@ matching, blocked and limited-domain policy, and person-only policy. ## Optional public website The bundled frontend is optional and has no effect on relay operation. +The default generated footer reads the configured status endpoint at runtime and +discloses the effective public-address distribution policy on every page. Operators may: 1. use the bundled generated site;