Federated via graft
0

Configure Feed

Select the types of activity you want to include in your feed.

v2 M1: ActivityPub identity + read-only outbox, /social page

Every series becomes a followable ActivityPub actor
(series@graft.cyberwild.org): WebFinger resolution, an Actor object with
a lazily-generated RSA-2048 keypair, an outbox wrapping activity_log
entries as Create{Note} activities, and per-entry Note objects at stable
URIs (/actors/{series}/notes/{id}) so future replies can address a
specific mirrored commit/issue/patch.

New /social page lists every series' ActivityPub endpoints, linked from
the main dashboard. AT Proto/Bluesky status shows as "not configured"
for now (M5).

Inbox/follow/delivery (M2), the comment bridge (M3), the Radicle DID
profile attachment (M4), and the AT Proto client (M5) are not built yet
— see the v2 plan.

+681 -18
+24 -2
cmd/sync/main.go
··· 12 12 "strings" 13 13 "time" 14 14 15 + "graft/internal/activitypub" 15 16 "graft/internal/config" 16 17 "graft/internal/state" 17 18 "graft/internal/status" ··· 54 55 syncers = append(syncers, rs) 55 56 } 56 57 58 + topology := buildTopology(cfg) 59 + 57 60 tracker := status.NewTracker(st, cfg.SourceURL) 58 - tracker.SetTopology(buildTopology(cfg)) 61 + tracker.SetTopology(topology) 62 + tracker.SetPublicHost(cfg.PublicHost) 59 63 runAll := func() { 60 64 for _, rs := range syncers { 61 65 gitErr, issuesErr, patchErr := rs.Run(log) ··· 64 68 } 65 69 66 70 if *listen != "" { 71 + mux := http.NewServeMux() 72 + if cfg.PublicHost != "" { 73 + apHandler := activitypub.NewHandler(st, cfg.PublicHost, 74 + func(series string) string { 75 + if refs := topology[series]; len(refs) > 0 { 76 + return refs[0].URL 77 + } 78 + return "" 79 + }, 80 + func(series string) bool { 81 + _, ok := topology[series] 82 + return ok 83 + }, 84 + ) 85 + apHandler.Register(mux) 86 + } 87 + mux.Handle("/", tracker.Handler()) 88 + 67 89 go func() { 68 - if err := http.ListenAndServe(*listen, tracker.Handler()); err != nil { 90 + if err := http.ListenAndServe(*listen, mux); err != nil { 69 91 log.Error("status server stopped", "err", err) 70 92 } 71 93 }()
+6
config.example.yaml
··· 8 8 # Shown in the status dashboard's footer. Optional. 9 9 source_url: https://git.example.org/alice/graft 10 10 11 + # Hostname graft is publicly reachable at. Setting this enables ActivityPub: 12 + # every series becomes a followable actor (e.g. testrepo@graft.example.org) 13 + # whose outbox mirrors its activity log, with a status page at /social. 14 + # Optional — leave unset to keep graft ActivityPub-free. 15 + public_host: graft.example.org 16 + 11 17 repos: 12 18 - name: testrepo 13 19 # Groups this pair with others that mirror the same underlying repo
+62
internal/activitypub/actor.go
··· 1 + package activitypub 2 + 3 + // Actor is the ActivityPub actor object for one series. Field set kept 4 + // intentionally minimal: what Mastodon needs to resolve, display, and 5 + // verify signed requests from this actor. 6 + type Actor struct { 7 + Context []string `json:"@context"` 8 + ID string `json:"id"` 9 + Type string `json:"type"` 10 + PreferredUsername string `json:"preferredUsername"` 11 + Name string `json:"name"` 12 + Summary string `json:"summary,omitempty"` 13 + URL string `json:"url,omitempty"` 14 + Inbox string `json:"inbox"` 15 + Outbox string `json:"outbox"` 16 + Followers string `json:"followers"` 17 + PublicKey PublicKey `json:"publicKey"` 18 + Attachment []Attachment `json:"attachment,omitempty"` 19 + } 20 + 21 + type PublicKey struct { 22 + ID string `json:"id"` 23 + Owner string `json:"owner"` 24 + PublicKeyPem string `json:"publicKeyPem"` 25 + } 26 + 27 + // Attachment is one profile field, rendered by Mastodon as a labeled value 28 + // on the actor's profile page — used to surface a series' Radicle DID. 29 + type Attachment struct { 30 + Type string `json:"type"` 31 + Name string `json:"name"` 32 + Value string `json:"value"` 33 + } 34 + 35 + // ActorURI returns the canonical actor URI for a series on host. 36 + func ActorURI(host, series string) string { 37 + return "https://" + host + "/actors/" + series 38 + } 39 + 40 + // BuildActor constructs the actor object for a series. repoURL links to 41 + // the underlying repo (its Forgejo or Radicle explorer page); publicKeyPEM 42 + // is that series' stored public key. 43 + func BuildActor(host, series, repoURL, publicKeyPEM string) Actor { 44 + base := ActorURI(host, series) 45 + return Actor{ 46 + Context: []string{"https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"}, 47 + ID: base, 48 + Type: "Service", 49 + PreferredUsername: series, 50 + Name: series, 51 + Summary: "Mirrored by graft — commits, issues, and patches for " + series + ".", 52 + URL: repoURL, 53 + Inbox: base + "/inbox", 54 + Outbox: base + "/outbox", 55 + Followers: base + "/followers", 56 + PublicKey: PublicKey{ 57 + ID: base + "#main-key", 58 + Owner: base, 59 + PublicKeyPem: publicKeyPEM, 60 + }, 61 + } 62 + }
+171
internal/activitypub/handlers.go
··· 1 + package activitypub 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "strconv" 7 + "strings" 8 + 9 + "graft/internal/state" 10 + ) 11 + 12 + const ( 13 + activityContentType = "application/activity+json" 14 + outboxLimit = 50 15 + ) 16 + 17 + // Handler serves the ActivityPub surface for every series: WebFinger, 18 + // actor, outbox, individual notes, and (for now, always empty — see M2 in 19 + // the v2 plan) a followers collection. 20 + type Handler struct { 21 + store *state.Store 22 + host string 23 + // repoURL and known are supplied by the caller (cmd/sync/main.go), 24 + // which already knows every series' topology — avoids this package 25 + // needing to know about config.Config or sync.RepoSyncer at all. 26 + repoURL func(series string) string 27 + known func(series string) bool 28 + } 29 + 30 + func NewHandler(store *state.Store, host string, repoURL func(series string) string, known func(series string) bool) *Handler { 31 + return &Handler{store: store, host: host, repoURL: repoURL, known: known} 32 + } 33 + 34 + // Register mounts every ActivityPub route on mux. Call before mounting any 35 + // catch-all "/" handler — Go's ServeMux resolves the more specific 36 + // patterns registered here first regardless of registration order, but 37 + // keeping this explicit avoids surprises. 38 + func (h *Handler) Register(mux *http.ServeMux) { 39 + mux.HandleFunc("/.well-known/webfinger", h.webfinger) 40 + mux.HandleFunc("/actors/", h.actorRouter) 41 + } 42 + 43 + func (h *Handler) webfinger(w http.ResponseWriter, r *http.Request) { 44 + series, ok := parseAcct(r.URL.Query().Get("resource"), h.host) 45 + if !ok || !h.known(series) { 46 + http.NotFound(w, r) 47 + return 48 + } 49 + resp := map[string]any{ 50 + "subject": "acct:" + series + "@" + h.host, 51 + "links": []map[string]string{ 52 + {"rel": "self", "type": activityContentType, "href": ActorURI(h.host, series)}, 53 + }, 54 + } 55 + writeJSON(w, "application/jrd+json", resp) 56 + } 57 + 58 + // parseAcct extracts the series name from a WebFinger "acct:series@host" 59 + // resource query, rejecting anything not addressed to our own host. 60 + func parseAcct(resource, host string) (series string, ok bool) { 61 + resource = strings.TrimPrefix(resource, "acct:") 62 + at := strings.LastIndex(resource, "@") 63 + if at < 0 { 64 + return "", false 65 + } 66 + series, resourceHost := resource[:at], resource[at+1:] 67 + if series == "" || resourceHost != host { 68 + return "", false 69 + } 70 + return series, true 71 + } 72 + 73 + func (h *Handler) actorRouter(w http.ResponseWriter, r *http.Request) { 74 + parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/actors/"), "/") 75 + series := parts[0] 76 + if series == "" || !h.known(series) { 77 + http.NotFound(w, r) 78 + return 79 + } 80 + switch { 81 + case len(parts) == 1: 82 + h.actor(w, series) 83 + case len(parts) == 2 && parts[1] == "outbox": 84 + h.outbox(w, series) 85 + case len(parts) == 2 && parts[1] == "followers": 86 + h.followers(w, series) 87 + case len(parts) == 3 && parts[1] == "notes": 88 + h.note(w, r, series, parts[2]) 89 + default: 90 + http.NotFound(w, r) 91 + } 92 + } 93 + 94 + // actorKeys returns a series' stored keypair, generating and persisting 95 + // one the first time it's asked for. 96 + func (h *Handler) actorKeys(series string) (privPEM, pubPEM string, err error) { 97 + k, err := h.store.ActorKeys(series) 98 + if err != nil { 99 + return "", "", err 100 + } 101 + if k != nil { 102 + return k.PrivateKeyPEM, k.PublicKeyPEM, nil 103 + } 104 + priv, pub, err := GenerateKeyPair() 105 + if err != nil { 106 + return "", "", err 107 + } 108 + if err := h.store.SaveActorKeys(series, state.ActorKeys{PrivateKeyPEM: priv, PublicKeyPEM: pub}); err != nil { 109 + return "", "", err 110 + } 111 + // Re-read: SaveActorKeys is INSERT OR IGNORE, so a concurrent request 112 + // generating the same series' first keypair may have won the race — 113 + // this returns whichever copy actually landed in the database. 114 + k, err = h.store.ActorKeys(series) 115 + if err != nil { 116 + return "", "", err 117 + } 118 + return k.PrivateKeyPEM, k.PublicKeyPEM, nil 119 + } 120 + 121 + func (h *Handler) actor(w http.ResponseWriter, series string) { 122 + _, pub, err := h.actorKeys(series) 123 + if err != nil { 124 + http.Error(w, err.Error(), http.StatusInternalServerError) 125 + return 126 + } 127 + writeJSON(w, activityContentType, BuildActor(h.host, series, h.repoURL(series), pub)) 128 + } 129 + 130 + func (h *Handler) outbox(w http.ResponseWriter, series string) { 131 + entries, err := h.store.ActivityForSeries(series, outboxLimit) 132 + if err != nil { 133 + http.Error(w, err.Error(), http.StatusInternalServerError) 134 + return 135 + } 136 + writeJSON(w, activityContentType, BuildOutbox(h.host, series, entries)) 137 + } 138 + 139 + func (h *Handler) note(w http.ResponseWriter, r *http.Request, series, idStr string) { 140 + id, err := strconv.ParseInt(idStr, 10, 64) 141 + if err != nil { 142 + http.NotFound(w, r) 143 + return 144 + } 145 + e, err := h.store.ActivityByID(id) 146 + if err != nil { 147 + http.Error(w, err.Error(), http.StatusInternalServerError) 148 + return 149 + } 150 + if e == nil || e.Series != series { 151 + http.NotFound(w, r) 152 + return 153 + } 154 + writeJSON(w, activityContentType, BuildNote(h.host, series, *e)) 155 + } 156 + 157 + func (h *Handler) followers(w http.ResponseWriter, series string) { 158 + writeJSON(w, activityContentType, OrderedCollection{ 159 + Context: "https://www.w3.org/ns/activitystreams", 160 + ID: ActorURI(h.host, series) + "/followers", 161 + Type: "OrderedCollection", 162 + TotalItems: 0, 163 + OrderedItems: []Create{}, 164 + }) 165 + } 166 + 167 + func writeJSON(w http.ResponseWriter, contentType string, v any) { 168 + w.Header().Set("Content-Type", contentType) 169 + w.Header().Set("Cache-Control", "no-store") 170 + json.NewEncoder(w).Encode(v) 171 + }
+44
internal/activitypub/keys.go
··· 1 + // Package activitypub exposes graft's mirrored repos over ActivityPub: one 2 + // actor per series (e.g. @federation-x@graft.cyberwild.org), so anyone on 3 + // the fediverse can follow a repo and see its mirrored commits, issues, and 4 + // patches show up as posts. 5 + package activitypub 6 + 7 + import ( 8 + "crypto/rand" 9 + "crypto/rsa" 10 + "crypto/x509" 11 + "encoding/pem" 12 + "fmt" 13 + ) 14 + 15 + // GenerateKeyPair creates a new RSA-2048 keypair, PEM-encoded — RSA because 16 + // that's what HTTP Signatures (the scheme ActivityPub implementations like 17 + // Mastodon use to authenticate federated requests) expects in practice. 18 + func GenerateKeyPair() (privatePEM, publicPEM string, err error) { 19 + key, err := rsa.GenerateKey(rand.Reader, 2048) 20 + if err != nil { 21 + return "", "", fmt.Errorf("generate rsa key: %w", err) 22 + } 23 + 24 + privBytes := x509.MarshalPKCS1PrivateKey(key) 25 + priv := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: privBytes}) 26 + 27 + pubBytes, err := x509.MarshalPKIXPublicKey(&key.PublicKey) 28 + if err != nil { 29 + return "", "", fmt.Errorf("marshal public key: %w", err) 30 + } 31 + pub := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes}) 32 + 33 + return string(priv), string(pub), nil 34 + } 35 + 36 + // ParsePrivateKey decodes a PEM-encoded RSA private key, as stored via 37 + // GenerateKeyPair. 38 + func ParsePrivateKey(pemStr string) (*rsa.PrivateKey, error) { 39 + block, _ := pem.Decode([]byte(pemStr)) 40 + if block == nil { 41 + return nil, fmt.Errorf("no PEM block found in private key") 42 + } 43 + return x509.ParsePKCS1PrivateKey(block.Bytes) 44 + }
+104
internal/activitypub/outbox.go
··· 1 + package activitypub 2 + 3 + import ( 4 + "strconv" 5 + "time" 6 + 7 + "graft/internal/state" 8 + ) 9 + 10 + // OrderedCollection wraps a series' outbox: every mirrored event as a 11 + // Create{Note} activity, newest first. 12 + type OrderedCollection struct { 13 + Context string `json:"@context"` 14 + ID string `json:"id"` 15 + Type string `json:"type"` 16 + TotalItems int `json:"totalItems"` 17 + OrderedItems []Create `json:"orderedItems"` 18 + } 19 + 20 + type Create struct { 21 + Context string `json:"@context,omitempty"` 22 + ID string `json:"id"` 23 + Type string `json:"type"` 24 + Actor string `json:"actor"` 25 + Published string `json:"published"` 26 + To []string `json:"to,omitempty"` 27 + Object Note `json:"object"` 28 + } 29 + 30 + // Note is one mirrored event, addressable on its own 31 + // (/actors/{series}/notes/{id}) so a reply's inReplyTo can point back at 32 + // the specific commit/issue/patch it's about. 33 + type Note struct { 34 + Context string `json:"@context,omitempty"` 35 + ID string `json:"id"` 36 + Type string `json:"type"` 37 + AttributedTo string `json:"attributedTo"` 38 + Content string `json:"content"` 39 + URL string `json:"url,omitempty"` 40 + Published string `json:"published"` 41 + To []string `json:"to,omitempty"` 42 + } 43 + 44 + const publicAudience = "https://www.w3.org/ns/activitystreams#Public" 45 + 46 + // NoteURI returns the stable object URI for one activity_log entry's Note. 47 + func NoteURI(host, series string, entryID int64) string { 48 + return ActorURI(host, series) + "/notes/" + strconv.FormatInt(entryID, 10) 49 + } 50 + 51 + // BuildNote turns one mirrored event into its Note representation. 52 + func BuildNote(host, series string, e state.ActivityEntry) Note { 53 + actor := ActorURI(host, series) 54 + content := kindLabel(e.Kind) + ": " + e.Summary 55 + return Note{ 56 + ID: NoteURI(host, series, e.ID), 57 + Type: "Note", 58 + AttributedTo: actor, 59 + Content: content, 60 + URL: e.URL, 61 + Published: e.OccurredAt.Format(time.RFC3339), 62 + To: []string{publicAudience}, 63 + } 64 + } 65 + 66 + // BuildOutbox wraps a series' recent activity entries (newest first, as 67 + // returned by state.Store.ActivityForSeries) into an OrderedCollection of 68 + // Create activities. 69 + func BuildOutbox(host, series string, entries []state.ActivityEntry) OrderedCollection { 70 + base := ActorURI(host, series) 71 + actor := base 72 + items := make([]Create, 0, len(entries)) 73 + for _, e := range entries { 74 + note := BuildNote(host, series, e) 75 + items = append(items, Create{ 76 + ID: note.ID + "/activity", 77 + Type: "Create", 78 + Actor: actor, 79 + Published: note.Published, 80 + To: []string{publicAudience}, 81 + Object: note, 82 + }) 83 + } 84 + return OrderedCollection{ 85 + Context: "https://www.w3.org/ns/activitystreams", 86 + ID: base + "/outbox", 87 + Type: "OrderedCollection", 88 + TotalItems: len(items), 89 + OrderedItems: items, 90 + } 91 + } 92 + 93 + func kindLabel(kind string) string { 94 + switch kind { 95 + case "git": 96 + return "Commit" 97 + case "issue": 98 + return "Issue" 99 + case "patch": 100 + return "Patch" 101 + default: 102 + return kind 103 + } 104 + }
+4
internal/config/config.go
··· 17 17 // SourceURL is graft's own source repository, shown in the status 18 18 // dashboard's footer. Optional. 19 19 SourceURL string `yaml:"source_url"` 20 + // PublicHost is the hostname graft is reachable at (e.g. 21 + // graft.cyberwild.org), used to build ActivityPub actor/acct URIs. 22 + // Required only if any series should be reachable over ActivityPub. 23 + PublicHost string `yaml:"public_host"` 20 24 } 21 25 22 26 // RepoPair links one Forgejo repository to one Radicle repository and
+92 -5
internal/state/store.go
··· 75 75 76 76 CREATE INDEX IF NOT EXISTS idx_activity_log_occurred_at 77 77 ON activity_log (occurred_at); 78 + 79 + -- One row per series' ActivityPub actor: its RSA keypair, generated once 80 + -- lazily the first time that series is served over ActivityPub. 81 + CREATE TABLE IF NOT EXISTS ap_actor ( 82 + series TEXT PRIMARY KEY, 83 + private_key TEXT NOT NULL, 84 + public_key TEXT NOT NULL, 85 + created_at TEXT NOT NULL 86 + ); 78 87 `) 79 88 if err != nil { 80 89 return err ··· 165 174 166 175 // Activity is one thing that was actually mirrored, for the status page. 167 176 type Activity struct { 168 - RepoPair string // the sync pair that did it, e.g. "graft-test-v2-f1" 169 - Series string // the human-facing repo group this pair belongs to, 177 + RepoPair string // the sync pair that did it, e.g. "graft-test-v2-f1" 178 + Series string // the human-facing repo group this pair belongs to, 170 179 // e.g. "graft-test-v2" — several pairs (one per forge/Radicle side) 171 180 // can share a series so the dashboard shows one row per repo, not 172 181 // one per pair. ··· 199 208 200 209 // ActivityEntry is one row of activity_log. 201 210 type ActivityEntry struct { 211 + ID int64 // activity_log.id, used as the stable suffix of ActivityPub Note URIs 202 212 Activity 203 213 OccurredAt time.Time 204 214 } ··· 206 216 // ActivitySince returns every activity entry at or after since, newest first. 207 217 func (s *Store) ActivitySince(since time.Time) ([]ActivityEntry, error) { 208 218 rows, err := s.db.Query(` 209 - SELECT repo_pair, kind, direction, summary, url, series, series_url, occurred_at FROM activity_log 219 + SELECT id, repo_pair, kind, direction, summary, url, series, series_url, occurred_at FROM activity_log 210 220 WHERE occurred_at >= ? 211 221 ORDER BY occurred_at DESC 212 222 `, since.UTC().Format(time.RFC3339)) 213 223 if err != nil { 214 224 return nil, err 215 225 } 216 - defer rows.Close() 226 + return scanActivityRows(rows) 227 + } 228 + 229 + // ActivityForSeries returns the most recent activity entries for one 230 + // series, newest first, capped at limit — used to build that series' 231 + // ActivityPub outbox. 232 + func (s *Store) ActivityForSeries(series string, limit int) ([]ActivityEntry, error) { 233 + rows, err := s.db.Query(` 234 + SELECT id, repo_pair, kind, direction, summary, url, series, series_url, occurred_at FROM activity_log 235 + WHERE series = ? 236 + ORDER BY occurred_at DESC 237 + LIMIT ? 238 + `, series, limit) 239 + if err != nil { 240 + return nil, err 241 + } 242 + return scanActivityRows(rows) 243 + } 244 + 245 + // ActivityByID looks up one activity_log row by id, e.g. to resolve an 246 + // ActivityPub Note URI (/actors/{series}/notes/{id}) back to the mirrored 247 + // item it describes. 248 + func (s *Store) ActivityByID(id int64) (*ActivityEntry, error) { 249 + row := s.db.QueryRow(` 250 + SELECT id, repo_pair, kind, direction, summary, url, series, series_url, occurred_at FROM activity_log 251 + WHERE id = ? 252 + `, id) 253 + var e ActivityEntry 254 + var occurredAt string 255 + err := row.Scan(&e.ID, &e.RepoPair, &e.Kind, &e.Direction, &e.Summary, &e.URL, &e.Series, &e.SeriesURL, &occurredAt) 256 + if err == sql.ErrNoRows { 257 + return nil, nil 258 + } 259 + if err != nil { 260 + return nil, err 261 + } 262 + e.OccurredAt, err = time.Parse(time.RFC3339, occurredAt) 263 + if err != nil { 264 + return nil, fmt.Errorf("parse occurred_at %q: %w", occurredAt, err) 265 + } 266 + return &e, nil 267 + } 217 268 269 + func scanActivityRows(rows *sql.Rows) ([]ActivityEntry, error) { 270 + defer rows.Close() 218 271 var out []ActivityEntry 219 272 for rows.Next() { 220 273 var e ActivityEntry 221 274 var occurredAt string 222 - if err := rows.Scan(&e.RepoPair, &e.Kind, &e.Direction, &e.Summary, &e.URL, &e.Series, &e.SeriesURL, &occurredAt); err != nil { 275 + if err := rows.Scan(&e.ID, &e.RepoPair, &e.Kind, &e.Direction, &e.Summary, &e.URL, &e.Series, &e.SeriesURL, &occurredAt); err != nil { 223 276 return nil, err 224 277 } 278 + var err error 225 279 e.OccurredAt, err = time.Parse(time.RFC3339, occurredAt) 226 280 if err != nil { 227 281 return nil, fmt.Errorf("parse occurred_at %q: %w", occurredAt, err) ··· 230 284 } 231 285 return out, rows.Err() 232 286 } 287 + 288 + // ActorKeys is one series' ActivityPub actor keypair, PEM-encoded. 289 + type ActorKeys struct { 290 + PrivateKeyPEM string 291 + PublicKeyPEM string 292 + } 293 + 294 + // ActorKeys returns the stored keypair for a series, or nil if none has 295 + // been generated yet. 296 + func (s *Store) ActorKeys(series string) (*ActorKeys, error) { 297 + row := s.db.QueryRow(`SELECT private_key, public_key FROM ap_actor WHERE series = ?`, series) 298 + var k ActorKeys 299 + err := row.Scan(&k.PrivateKeyPEM, &k.PublicKeyPEM) 300 + if err == sql.ErrNoRows { 301 + return nil, nil 302 + } 303 + if err != nil { 304 + return nil, err 305 + } 306 + return &k, nil 307 + } 308 + 309 + // SaveActorKeys stores a newly generated keypair for a series. Safe to call 310 + // concurrently: if another goroutine already inserted one, this becomes a 311 + // no-op via INSERT OR IGNORE, and the caller should re-read with 312 + // ActorKeys to get whichever copy won. 313 + func (s *Store) SaveActorKeys(series string, k ActorKeys) error { 314 + _, err := s.db.Exec(` 315 + INSERT OR IGNORE INTO ap_actor (series, private_key, public_key, created_at) 316 + VALUES (?, ?, ?, ?) 317 + `, series, k.PrivateKeyPEM, k.PublicKeyPEM, time.Now().UTC().Format(time.RFC3339)) 318 + return err 319 + }
+174 -11
internal/status/status.go
··· 47 47 store *state.Store 48 48 sourceURL string 49 49 50 - mu sync.Mutex 51 - pairs map[string]PairResult 52 - topology map[string][]ServerRef 50 + mu sync.Mutex 51 + pairs map[string]PairResult 52 + topology map[string][]ServerRef 53 + publicHost string 53 54 } 54 55 55 56 func NewTracker(store *state.Store, sourceURL string) *Tracker { ··· 64 65 t.mu.Lock() 65 66 defer t.mu.Unlock() 66 67 t.topology = topology 68 + } 69 + 70 + // SetPublicHost declares the host graft is reachable at (e.g. 71 + // graft.cyberwild.org), enabling the "Social bridges" link and the 72 + // /social page. Call with "" (the default) to leave ActivityPub disabled. 73 + func (t *Tracker) SetPublicHost(host string) { 74 + t.mu.Lock() 75 + defer t.mu.Unlock() 76 + t.publicHost = host 67 77 } 68 78 69 79 // Record stores the outcome of a pass for one pair. Pass nil for a scope ··· 126 136 }) 127 137 128 138 mux.HandleFunc("/", t.serveDashboard) 139 + mux.HandleFunc("/social", t.serveSocial) 129 140 130 141 return mux 131 142 } ··· 148 159 149 160 t.mu.Lock() 150 161 topology := t.topology 162 + publicHost := t.publicHost 151 163 t.mu.Unlock() 152 164 153 165 data := dashboardData{ 154 - Series: buildSeriesRows(entries, health, topology), 155 - Activity: recentActivity(entries), 156 - SourceURL: t.sourceURL, 166 + Series: buildSeriesRows(entries, health, topology), 167 + Activity: recentActivity(entries), 168 + SourceURL: t.sourceURL, 169 + SocialEnabled: publicHost != "", 157 170 } 158 171 w.Header().Set("Content-Type", "text/html; charset=utf-8") 159 172 w.Header().Set("Cache-Control", "no-store") ··· 162 175 } 163 176 } 164 177 178 + // socialData is what the /social page renders: one row per series 179 + // describing its ActivityPub and AT Proto/Bluesky bridges. Built 180 + // incrementally across the v2 milestones — see the plan for what each 181 + // stage adds (follower counts, delivery timestamps, bridged-comment 182 + // counts, Bluesky status). 183 + type socialData struct { 184 + PublicHost string 185 + Series []socialRow 186 + SourceURL string 187 + } 188 + 189 + type socialRow struct { 190 + Name string 191 + Handle string // "series@host" 192 + WebfingerURL string 193 + ActorURL string 194 + OutboxURL string 195 + } 196 + 197 + func (t *Tracker) serveSocial(w http.ResponseWriter, r *http.Request) { 198 + t.mu.Lock() 199 + topology := t.topology 200 + publicHost := t.publicHost 201 + t.mu.Unlock() 202 + 203 + if publicHost == "" { 204 + http.NotFound(w, r) 205 + return 206 + } 207 + 208 + names := make([]string, 0, len(topology)) 209 + for name := range topology { 210 + names = append(names, name) 211 + } 212 + sort.Strings(names) 213 + 214 + rows := make([]socialRow, 0, len(names)) 215 + for _, name := range names { 216 + actorURL := "https://" + publicHost + "/actors/" + name 217 + rows = append(rows, socialRow{ 218 + Name: name, 219 + Handle: name + "@" + publicHost, 220 + WebfingerURL: "https://" + publicHost + "/.well-known/webfinger?resource=acct:" + name + "@" + publicHost, 221 + ActorURL: actorURL, 222 + OutboxURL: actorURL + "/outbox", 223 + }) 224 + } 225 + 226 + data := socialData{PublicHost: publicHost, Series: rows, SourceURL: t.sourceURL} 227 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 228 + w.Header().Set("Cache-Control", "no-store") 229 + if err := socialTmpl.Execute(w, data); err != nil { 230 + http.Error(w, err.Error(), http.StatusInternalServerError) 231 + } 232 + } 233 + 165 234 type dashboardData struct { 166 - Series []seriesRow 167 - Activity []commitLine 168 - SourceURL string 235 + Series []seriesRow 236 + Activity []commitLine 237 + SourceURL string 238 + SocialEnabled bool 169 239 } 170 240 171 241 // pairStatus is one badge in the dashboard's top status card: one per ··· 489 559 main { max-width: 920px; margin: 0 auto; padding: 2.5rem 1.5rem 1rem; } 490 560 491 561 .top { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 1.5rem; } 562 + .top > div { display: flex; align-items: baseline; gap: .6rem; } 492 563 h1 { font-size: 1.25rem; margin: 0; font-weight: 600; } 493 564 .sub { color: var(--text-muted); font-size: .85rem; } 565 + .nav-link { color: var(--brand); font-size: .82rem; text-decoration: none; font-weight: 500; } 566 + .nav-link:hover { text-decoration: underline; } 494 567 495 568 .card { 496 569 background: var(--surface); ··· 598 671 <body> 599 672 <main> 600 673 <div class="top"> 601 - <h1>graft</h1> 602 - <span class="sub">Forgejo &lt;-&gt; Radicle sync — last 3 days</span> 674 + <div> 675 + <h1>graft</h1> 676 + <span class="sub">Forgejo &lt;-&gt; Radicle sync — last 3 days</span> 677 + </div> 678 + {{if .SocialEnabled}}<a class="nav-link" href="/social">Social bridges &rarr;</a>{{end}} 603 679 </div> 604 680 605 681 <div class="card"> ··· 684 760 </body> 685 761 </html> 686 762 `)) 763 + 764 + var socialTmpl = template.Must(template.New("social").Parse(`<!doctype html> 765 + <html lang="en"> 766 + <head> 767 + <meta charset="utf-8"> 768 + <meta name="viewport" content="width=device-width, initial-scale=1"> 769 + <title>graft — social bridges</title> 770 + <style> 771 + :root { 772 + color-scheme: light; 773 + --surface: #ffffff; 774 + --surface-sunk: #f4f5f7; 775 + --text: #172b4d; 776 + --text-muted: #6b778c; 777 + --border: #dfe1e6; 778 + --brand: #0052cc; 779 + } 780 + * { box-sizing: border-box; } 781 + body { 782 + margin: 0; background: var(--surface-sunk); color: var(--text); 783 + font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 784 + } 785 + main { max-width: 760px; margin: 0 auto; padding: 2.5rem 1.5rem 1rem; } 786 + .top { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 1.5rem; } 787 + .top > div { display: flex; align-items: baseline; gap: .6rem; } 788 + h1 { font-size: 1.25rem; margin: 0; font-weight: 600; } 789 + .sub { color: var(--text-muted); font-size: .85rem; } 790 + .nav-link { color: var(--brand); font-size: .82rem; text-decoration: none; font-weight: 500; } 791 + .nav-link:hover { text-decoration: underline; } 792 + .card { 793 + background: var(--surface); border: 1px solid var(--border); border-radius: 4px; 794 + padding: 1.25rem 1.5rem; margin-bottom: 1.25rem; 795 + } 796 + .series-block { padding: .75rem 0; border-bottom: 1px solid var(--border); } 797 + .series-block:last-child { border-bottom: none; } 798 + .handle { font-size: .95rem; font-weight: 600; color: var(--text); } 799 + h2 { font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); margin: .6rem 0 .4rem; font-weight: 600; } 800 + .endpoints { display: flex; flex-direction: column; gap: .3rem; } 801 + .endpoints a { font-size: .8rem; color: var(--brand); text-decoration: none; font-family: ui-monospace, monospace; } 802 + .endpoints a:hover { text-decoration: underline; } 803 + .badge { 804 + display: inline-block; font-size: .72rem; color: var(--text-muted); background: var(--surface-sunk); 805 + border: 1px solid var(--border); border-radius: 3px; padding: .15rem .5rem; margin-top: .4rem; 806 + } 807 + .empty { color: var(--text-muted); padding: .5rem 0; } 808 + footer { 809 + max-width: 760px; margin: 0 auto; padding: 1rem 1.5rem 2.5rem; 810 + color: var(--text-muted); font-size: .78rem; display: flex; gap: 1rem; 811 + } 812 + footer a { color: var(--text-muted); } 813 + </style> 814 + </head> 815 + <body> 816 + <main> 817 + <div class="top"> 818 + <div> 819 + <h1>graft — social bridges</h1> 820 + <span class="sub">ActivityPub + AT Proto, per repo</span> 821 + </div> 822 + <a class="nav-link" href="/">&larr; Dashboard</a> 823 + </div> 824 + 825 + <div class="card"> 826 + {{range .Series}} 827 + <div class="series-block"> 828 + <div class="handle">@{{.Handle}}</div> 829 + <h2>ActivityPub</h2> 830 + <div class="endpoints"> 831 + <a href="{{.WebfingerURL}}" target="_blank" rel="noopener">WebFinger</a> 832 + <a href="{{.ActorURL}}" target="_blank" rel="noopener">Actor</a> 833 + <a href="{{.OutboxURL}}" target="_blank" rel="noopener">Outbox</a> 834 + </div> 835 + <h2>AT Proto / Bluesky</h2> 836 + <span class="badge">not configured</span> 837 + </div> 838 + {{else}} 839 + <span class="empty">no series configured</span> 840 + {{end}} 841 + </div> 842 + </main> 843 + <footer> 844 + {{if .SourceURL}}<a href="{{.SourceURL}}" target="_blank" rel="noopener">{{.SourceURL}}</a>{{end}} 845 + <span>GPLv3</span> 846 + </footer> 847 + </body> 848 + </html> 849 + `))