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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
44 changes: 25 additions & 19 deletions api/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()
}
Expand Down Expand Up @@ -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",
Expand Down
22 changes: 20 additions & 2 deletions api/web_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
7 changes: 6 additions & 1 deletion contrib/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:

Expand Down
3 changes: 3 additions & 0 deletions contrib/web/assets/relay.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }

Expand Down
74 changes: 65 additions & 9 deletions contrib/web/assets/relay.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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 = [];

Expand All @@ -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();
Expand Down Expand Up @@ -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");
Expand All @@ -89,7 +148,6 @@
item.append(heading, meta);
publisherList.appendChild(item);
}

if (publisherEmpty) publisherEmpty.hidden = visible.length !== 0;
}

Expand Down Expand Up @@ -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()
Expand All @@ -153,9 +210,9 @@
publishers = Array.isArray(data.publishers?.entries)
? data.publishers.entries
: [];

renderDomains();
renderPublishers();
renderPolicy(data);
setStatusMessage("", true);
})
.catch((error) => {
Expand All @@ -166,14 +223,13 @@
setText(receivingCount, "—");
setText(publisherCount, "—");
setText(version, "—");

if (list) {
list.innerHTML = '<li class="muted">The live participating-server list is temporarily unavailable.</li>';
}
if (publisherList) {
publisherList.innerHTML = '<li class="muted">The live publisher list is temporarily unavailable.</li>';
}

renderPolicyUnavailable();
setStatusMessage(
`Unable to load relay status: ${error.message}`,
false
Expand Down
7 changes: 7 additions & 0 deletions contrib/web/content/footer.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
<footer class="site-footer">
<section class="relay-policy-status" data-relay-policy-status data-status-url="{{STATUS_URL}}" aria-live="polite">
<p class="relay-policy-summary">
<strong>Public-post distribution:</strong>
<span id="relay-policy-label">Loading current policy…</span>
</p>
<p class="muted relay-policy-description" id="relay-policy-description">The effective public-address policy is loaded from this relay's public status endpoint.</p>
</section>
<p>Operated by {{OPERATOR_ID_HTML}} · <a href="{{CONTACT_URL}}">Contact</a> · <a href="{{SOURCE_URL}}">Source</a></p>
<p class="muted">Generated {{YEAR}}. ActivityPub is a decentralized social networking protocol.</p>
</footer>
23 changes: 23 additions & 0 deletions contrib/web/test_build_site.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions models/public_address_policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
32 changes: 32 additions & 0 deletions models/public_address_policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading