Federated via graft
5.2 kB
171 lines
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}