Federated via graft
0

Configure Feed

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

1package activitypub 2 3import ( 4 "encoding/json" 5 "net/http" 6 "strconv" 7 "strings" 8 9 "graft/internal/state" 10) 11 12const ( 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. 20type 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 30func 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. 38func (h *Handler) Register(mux *http.ServeMux) { 39 mux.HandleFunc("/.well-known/webfinger", h.webfinger) 40 mux.HandleFunc("/actors/", h.actorRouter) 41} 42 43func (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. 60func 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 73func (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. 96func (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 121func (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 130func (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 139func (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 157func (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 167func 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}