Blob


1 // apub is an implementation of the ActivityPub protocol.
2 //
3 // https://www.w3.org/TR/activitypub/
4 // https://www.w3.org/TR/activitystreams-core/
5 // https://www.w3.org/TR/activitystreams-vocabulary/
6 package apub
8 import (
9 "bytes"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "io"
14 "net/mail"
15 "strings"
16 "time"
17 )
19 // NormContext is a URL referencing the
20 // normative Activity Streams 2.0 JSON-LD @context definition.
21 // All [Activity] variables should have their AtContext field set to this value.
22 // See [Activity Streams 2.0] section 2.1.
23 //
24 // [Activity Streams 2.0]: https://www.w3.org/TR/activitystreams-core/
25 const NormContext string = "https://www.w3.org/ns/activitystreams"
27 // ContentType is the MIME media type for ActivityPub.
28 const ContentType string = "application/activity+json"
30 // PublicCollection is the ActivityPub ID for the special collection indicating public access.
31 // Any Activity addressed to this collection is meant to be available to all users,
32 // authenticated or not.
33 // See W3C Recommendation ActivityPub Section 5.6.
34 const PublicCollection string = "https://www.w3.org/ns/activitystreams#Public"
36 var ErrNotExist = errors.New("no such activity")
38 // Activity represents the Activity Streams Object core type.
39 // See Activity Streams 2.0, section 4.1.
40 type Activity struct {
41 AtContext string `json:"@context"`
42 ID string `json:"id,omitempty"`
43 Type string `json:"type"`
44 Name string `json:"name,omitempty"`
45 Actor string `json:"actor,omitempty"`
46 Username string `json:"preferredUsername,omitempty"`
47 Summary string `json:"summary,omitempty"`
48 Inbox string `json:"inbox,omitempty"`
49 Outbox string `json:"outbox,omitempty"`
50 To []string `json:"to,omitempty"`
51 CC []string `json:"cc,omitempty"`
52 Followers string `json:"followers,omitempty"`
53 InReplyTo string `json:"inReplyTo,omitempty"`
54 Published *time.Time `json:"published,omitempty"`
55 AttributedTo string `json:"attributedTo,omitempty"`
56 Content string `json:"content,omitempty"`
57 MediaType string `json:"mediaType,omitempty"`
58 Source struct {
59 Content string `json:"content,omitempty"`
60 MediaType string `json:"mediaType,omitempty"`
61 } `json:"source,omitempty"`
62 PublicKey *PublicKey `json:"publicKey,omitempty"`
63 Audience string `json:"audience,omitempty"`
64 Href string `json:"href,omitempty"`
65 Tag []Activity `json:"tag,omitempty"`
66 Endpoints Endpoints `json:"endpoints,omitempty"`
67 // Contains a JSON-encoded Activity, or a URL as a JSON string
68 // pointing to an Activity. Use Activity.Unwrap() to access
69 // the enclosed, decoded value.
70 Object json.RawMessage `json:"object,omitempty"`
71 }
73 func (act *Activity) UnmarshalJSON(b []byte) error {
74 type Alias Activity
75 aux := &struct {
76 AtContext interface{} `json:"@context"`
77 Object interface{}
78 *Alias
79 }{
80 Alias: (*Alias)(act),
81 }
82 if err := json.Unmarshal(b, &aux); err != nil {
83 return err
84 }
85 switch v := aux.AtContext.(type) {
86 case string:
87 act.AtContext = v
88 case []interface{}:
89 if vv, ok := v[0].(string); ok {
90 act.AtContext = vv
91 }
92 }
93 return nil
94 }
96 // Unwrap returns the JSON-encoded Activity, if any, enclosed in act.
97 // The Activity may be referenced by ID,
98 // in which case the activity is looked up by client or by
99 // apub.defaultClient if client is nil.
100 func (act *Activity) Unwrap(client *Client) (*Activity, error) {
101 if act.Object == nil {
102 return nil, errors.New("no wrapped activity")
105 var buf io.Reader
106 buf = bytes.NewReader(act.Object)
107 if strings.HasPrefix(string(act.Object), "https") {
108 if client == nil {
109 return Lookup(string(act.Object))
111 return client.Lookup(string(act.Object))
113 return Decode(buf)
116 func Decode(r io.Reader) (*Activity, error) {
117 var a Activity
118 if err := json.NewDecoder(r).Decode(&a); err != nil {
119 return nil, fmt.Errorf("decode activity: %w", err)
121 return &a, nil
124 func DecodeActor(r io.Reader) (*Actor, error) {
125 a, err := Decode(r)
126 if err != nil {
127 return nil, err
129 return activityToActor(a), nil
132 type Actor struct {
133 AtContext string `json:"@context"`
134 ID string `json:"id"`
135 Type string `json:"type"`
136 Name string `json:"name"`
137 Username string `json:"preferredUsername"`
138 Summary string `json:"summary,omitempty"`
139 Inbox string `json:"inbox"`
140 Outbox string `json:"outbox"`
141 Followers string `json:"followers"`
142 Endpoints Endpoints `json:"endpoints,omitempty"`
143 Published *time.Time `json:"published,omitempty"`
144 PublicKey PublicKey `json:"publicKey"`
147 type PublicKey struct {
148 ID string `json:"id"`
149 Owner string `json:"owner"`
150 PublicKeyPEM string `json:"publicKeyPem"`
153 // Address generates the most likely address of the Actor.
154 // The Actor's name (not the username) is used as the address' proper name, if present.
155 // Implementors should verify the address using WebFinger.
156 // For example, the address for the Actor ID
157 // https://hachyderm.io/users/otl is:
158 //
159 // "Oliver Lowe" <otl@hachyderm.io>
160 func (a *Actor) Address() *mail.Address {
161 if a.Username == "" && a.Name == "" {
162 return &mail.Address{"", a.ID}
164 trimmed := strings.TrimPrefix(a.ID, "https://")
165 host, _, _ := strings.Cut(trimmed, "/")
166 addr := fmt.Sprintf("%s@%s", a.Username, host)
167 return &mail.Address{a.Name, addr}
170 // FollowersAddress generates a non-standard address representing the Actor's followers
171 // using plus addressing.
172 // It is the Actor's address username part with a "+followers" suffix.
173 // The address cannot be resolved using WebFinger.
174 //
175 // For example, the followers address for Actor ID
176 // https://hachyderm.io/users/otl is:
177 //
178 // "Oliver Lowe (followers)" <otl+followers@hachyderm.io>
179 func (a *Actor) FollowersAddress() *mail.Address {
180 if a.Followers == "" {
181 return &mail.Address{"", ""}
183 addr := a.Address()
184 user, domain, found := strings.Cut(addr.Address, "@")
185 if !found {
186 return &mail.Address{"", ""}
188 addr.Address = fmt.Sprintf("%s+followers@%s", user, domain)
189 if addr.Name != "" {
190 addr.Name += " (followers)"
192 return addr
195 type Endpoints struct {
196 SharedInbox string `json:"sharedInbox,omitempty"`
199 // Inboxes returns a deduplicated slice of inbox endpoints ActivityPub clients should send to.
200 // Shared inboxes, if present, are selected over an Actor's personal inbox.
201 // See W3C Recommendation ActivityPub Section 7.1.3 Shared Inbox Delivery.
202 func Inboxes(actors []Actor) []string {
203 var inboxes []string
204 for _, a := range actors {
205 box := a.Inbox
206 if a.Endpoints.SharedInbox != "" {
207 box = a.Endpoints.SharedInbox
209 var match bool
210 for i := range inboxes {
211 if inboxes[i] == box {
212 match = true
213 break
216 if match {
217 continue
219 inboxes = append(inboxes, box)
221 return inboxes