| 1 | diff --git a/internal/daemon/connections_public_test.go b/internal/daemon/connections_public_test.go |
| 2 | new file mode 100644 |
| 3 | index 0000000..ae4908b |
| 4 | --- /dev/null |
| 5 | +++ b/internal/daemon/connections_public_test.go |
| 6 | @@ -0,0 +1,42 @@ |
| 7 | +package daemon |
| 8 | + |
| 9 | +import ( |
| 10 | + "context" |
| 11 | + "encoding/json" |
| 12 | + "strings" |
| 13 | + "testing" |
| 14 | +) |
| 15 | + |
| 16 | +func TestVisibleConnectionsFollowViewerRole(t *testing.T) { |
| 17 | + ctx := context.Background() |
| 18 | + _, tenant := testTenant(t) |
| 19 | + rows := `[{"template":"notes","visibility":"public"},{"template":"bookmarks","visibility":"members"},{"template":"relay-page","visibility":"owner"},{"template":"nope","visibility":"public"}]` |
| 20 | + if _, err := tenant.Execute(ctx, tenant.Policy().Owner, "setconnections", []json.RawMessage{json.RawMessage(rows)}); err != nil { |
| 21 | + t.Fatal(err) |
| 22 | + } |
| 23 | + names := func(actor string) string { |
| 24 | + value, err := tenant.Execute(ctx, actor, "connections", nil) |
| 25 | + if err != nil { |
| 26 | + t.Fatal(err) |
| 27 | + } |
| 28 | + raw, _ := json.Marshal(value) |
| 29 | + var list []map[string]any |
| 30 | + _ = json.Unmarshal(raw, &list) |
| 31 | + out := []string{} |
| 32 | + for _, item := range list { |
| 33 | + out = append(out, item["template"].(string)) |
| 34 | + } |
| 35 | + return strings.Join(out, ",") |
| 36 | + } |
| 37 | + if got := names(""); got != "notes" { |
| 38 | + t.Fatalf("guest sees %q", got) |
| 39 | + } |
| 40 | + if got := names(tenant.Policy().Owner); got != "notes,bookmarks,relay-page" { |
| 41 | + t.Fatalf("owner sees %q", got) |
| 42 | + } |
| 43 | + value, _ := tenant.Execute(ctx, "", "connections", nil) |
| 44 | + raw, _ := json.Marshal(value) |
| 45 | + if !strings.Contains(string(raw), "jumble.social") || !strings.Contains(string(raw), `"links"`) { |
| 46 | + t.Fatalf("connection is missing its template links: %s", raw) |
| 47 | + } |
| 48 | +} |
| 49 | diff --git a/internal/daemon/management.go b/internal/daemon/management.go |
| 50 | index 1c3ccd0..69d7e6e 100644 |
| 51 | --- a/internal/daemon/management.go |
| 52 | +++ b/internal/daemon/management.go |
| 53 | @@ -110,7 +110,7 @@ func managementAdapterMethod(method string) bool { |
| 54 | // are intentionally separate from the NIP-86 management methods so a public |
| 55 | // page cannot accidentally gain access to privileged search or export APIs. |
| 56 | func PublicMethods() []string { |
| 57 | - return []string{"queryevents", "eventdetail", "feed", "listview", "claiminvite"} |
| 58 | + return []string{"queryevents", "eventdetail", "feed", "listview", "claiminvite", "connections"} |
| 59 | } |
| 60 | |
| 61 | func publicManagementMethod(method string) bool { |
| 62 | @@ -151,6 +151,9 @@ func roleName(role string) string { |
| 63 | } |
| 64 | |
| 65 | func (t *Tenant) executePublicManagement(ctx context.Context, actor, method string, params []json.RawMessage) (any, bool, error) { |
| 66 | + if method == "connections" { |
| 67 | + return t.visibleConnections(ctx, actor) |
| 68 | + } |
| 69 | if method == "claiminvite" { |
| 70 | if strings.TrimSpace(actor) == "" { |
| 71 | return nil, true, errors.New("auth-required: claiminvite") |
| 72 | @@ -487,6 +490,57 @@ func featureEnabled(p policy.Policy, name string) bool { |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | +// visibleConnections lists configured connections the viewer may see, each |
| 77 | +// merged with its template so pages can render titles, links and QR codes. |
| 78 | +// Visibility "public" shows to everyone, "members" or "auth" to members, |
| 79 | +// moderators and the owner, and "owner" to the owner alone. Connections |
| 80 | +// whose feature is off are left out. |
| 81 | +func (t *Tenant) visibleConnections(ctx context.Context, actor string) (any, bool, error) { |
| 82 | + rows, _, err := t.connections(ctx) |
| 83 | + if err != nil { |
| 84 | + return nil, true, err |
| 85 | + } |
| 86 | + role := "" |
| 87 | + if strings.TrimSpace(actor) != "" { |
| 88 | + if role, err = t.community.Role(ctx, actor); err != nil { |
| 89 | + return nil, true, err |
| 90 | + } |
| 91 | + } |
| 92 | + catalog := map[string]templates.Connection{} |
| 93 | + for _, c := range templates.Connections() { |
| 94 | + catalog[c.Name] = c |
| 95 | + } |
| 96 | + p := t.Policy() |
| 97 | + result := make([]map[string]any, 0) |
| 98 | + for _, row := range rows.([]map[string]any) { |
| 99 | + name, _ := row["template"].(string) |
| 100 | + if name == "" { |
| 101 | + name, _ = row["name"].(string) |
| 102 | + } |
| 103 | + c, ok := catalog[name] |
| 104 | + if !ok || (c.Feature != "" && !featureEnabled(p, c.Feature)) { |
| 105 | + continue |
| 106 | + } |
| 107 | + visibility, _ := row["visibility"].(string) |
| 108 | + if visibility == "" { |
| 109 | + visibility = c.Visibility |
| 110 | + } |
| 111 | + switch visibility { |
| 112 | + case "public": |
| 113 | + case "members", "member", "auth": |
| 114 | + if role != "member" && role != "moderator" && role != "owner" { |
| 115 | + continue |
| 116 | + } |
| 117 | + default: |
| 118 | + if role != "owner" { |
| 119 | + continue |
| 120 | + } |
| 121 | + } |
| 122 | + result = append(result, map[string]any{"template": c.Name, "title": c.Title, "about": c.About, "app": c.App, "where": c.Where, "icon": c.Icon, "feature": c.Feature, "visibility": visibility, "qr": c.QR, "inputs": c.Inputs, "links": c.Links}) |
| 123 | + } |
| 124 | + return result, true, nil |
| 125 | +} |
| 126 | + |
| 127 | func (t *Tenant) connections(ctx context.Context) (any, bool, error) { |
| 128 | rows, err := t.store.DB().QueryContext(ctx, `SELECT value FROM config_connections ORDER BY position`) |
| 129 | if err != nil { |