1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
|
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package federation
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"slices"
errorsv2 "codeberg.org/gruf/go-errors/v2"
"codeberg.org/gruf/go-kv"
"github.com/superseriousbusiness/activity/pub"
"github.com/superseriousbusiness/activity/streams/vocab"
"github.com/superseriousbusiness/gotosocial/internal/ap"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/uris"
)
// federatingActor wraps the pub.FederatingActor
// with some custom GoToSocial-specific logic.
type federatingActor struct {
sideEffectActor pub.DelegateActor
wrapped pub.FederatingActor
}
func deliveryRecipientPreSort(actorAndCollectionIRIs []*url.URL) []*url.URL {
var (
thisHost = config.GetHost()
thisAcctDomain = config.GetAccountDomain()
)
slices.SortFunc(
actorAndCollectionIRIs,
func(a *url.URL, b *url.URL) int {
// We want to sort by putting more specific actor URIs *before* collection URIs.
// Since the only collection URIs we ever address are our own followers URIs, we
// can just use host and regexes to identify these collections, and shove them
// to the back of the slice. This ensures that directly addressed (ie., mentioned)
// accounts get delivery-attempted *first*, and then delivery attempts move on to
// followers of the author. This should have the effect of making conversation
/// threads feel more snappy, as replies will be sent quicker to participants.
var (
aIsFollowers = (a.Host == thisHost || a.Host == thisAcctDomain) && uris.IsFollowersPath(a)
bIsFollowers = (b.Host == thisHost || b.Host == thisAcctDomain) && uris.IsFollowersPath(b)
)
switch {
case aIsFollowers == bIsFollowers:
// Both followers URIs or
// both not followers URIs,
// order doesn't matter.
return 0
case aIsFollowers:
// a is followers
// URI, b is not.
//
// Sort b before a.
return 1
default:
// b is followers
// URI, a is not.
//
// Sort a before b.
return -1
}
},
)
return actorAndCollectionIRIs
}
// newFederatingActor returns a federatingActor.
func newFederatingActor(c pub.CommonBehavior, s2s pub.FederatingProtocol, db pub.Database, clock pub.Clock) pub.FederatingActor {
sideEffectActor := pub.NewSideEffectActor(c, s2s, nil, db, clock)
// Hook in our own custom Serialize function.
sideEffectActor.Serialize = ap.Serialize
// Hook in our own custom recipient pre-sort function.
sideEffectActor.DeliveryRecipientPreSort = deliveryRecipientPreSort
return &federatingActor{
sideEffectActor: sideEffectActor,
wrapped: pub.NewCustomActor(sideEffectActor, false, true, clock),
}
}
// PostInboxScheme is a reimplementation of the default baseActor
// implementation of PostInboxScheme in pub/base_actor.go.
//
// Key differences from that implementation:
// - More explicit debug logging when a request is not processed.
// - Normalize content of activity object.
// - *ALWAYS* return gtserror.WithCode if there's an issue, to
// provide more helpful messages to remote callers.
// - Return code 202 instead of 200 on successful POST, to reflect
// that we process most side effects asynchronously.
func (f *federatingActor) PostInboxScheme(ctx context.Context, w http.ResponseWriter, r *http.Request, scheme string) (bool, error) {
l := log.WithContext(ctx).
WithFields([]kv.Field{
{"userAgent", r.UserAgent()},
{"path", r.URL.Path},
}...)
// Ensure valid ActivityPub Content-Type.
// https://www.w3.org/TR/activitypub/#server-to-server-interactions
if ct := r.Header.Get("Content-Type"); !apiutil.ASContentType(ct) {
const ct1 = "application/activity+json"
const ct2 = "application/ld+json;profile=https://w3.org/ns/activitystreams"
err := fmt.Errorf("Content-Type %s not acceptable, this endpoint accepts: [%q %q]", ct, ct1, ct2)
return false, gtserror.NewErrorNotAcceptable(err)
}
// Authenticate request by checking http signature.
//
// NOTE: the behaviour here is a little strange as we have
// the competing code styles of the go-fed interface expecting
// that any 'err' is fatal, but 'authenticated' bool is intended to
// be the main passer of whether failed auth occurred, but we in
// the gts codebase use errors to pass-back non-200 status codes,
// so we specifically have to check for already wrapped with code.
//
ctx, authenticated, err := f.sideEffectActor.AuthenticatePostInbox(ctx, w, r)
if errorsv2.AsV2[gtserror.WithCode](err) != nil {
// If it was already wrapped with an
// HTTP code then don't bother rewrapping
// it, just return it as-is for caller to
// handle. AuthenticatePostInbox already
// calls WriteHeader() in some situations.
return false, err
} else if err != nil {
err := gtserror.Newf("error authenticating post inbox: %w", err)
return false, gtserror.NewErrorInternalError(err)
}
if !authenticated {
const text = "not authenticated"
return false, gtserror.NewErrorUnauthorized(errors.New(text), text)
}
/*
Begin processing the request, but note that we
have not yet applied authorization (ie., blocks).
*/
// Resolve the activity, rejecting badly formatted / transient.
activity, ok, errWithCode := ap.ResolveIncomingActivity(r)
if errWithCode != nil {
return false, errWithCode
} else if !ok { // transient
return false, nil
}
// Set additional context data. Primarily this means
// looking at the Activity and seeing which IRIs are
// involved in it tangentially.
ctx, err = f.sideEffectActor.PostInboxRequestBodyHook(ctx, r, activity)
if err != nil {
err := gtserror.Newf("error during post inbox request body hook: %w", err)
return false, gtserror.NewErrorInternalError(err)
}
// Check authorization of the activity; this will include blocks.
authorized, err := f.sideEffectActor.AuthorizePostInbox(ctx, w, activity)
if err != nil {
if errorsv2.AsV2[*errOtherIRIBlocked](err) != nil {
// There's no direct block between requester(s) and
// receiver. However, one or more of the other IRIs
// involved in the request (account replied to, note
// boosted, etc) is blocked either at domain level or
// by the receiver. We don't need to return 403 here,
// instead, just return 202 accepted but don't do any
// further processing of the activity.
return true, nil //nolint
}
// Real error has occurred.
err := gtserror.Newf("error authorizing post inbox: %w", err)
return false, gtserror.NewErrorInternalError(err)
}
if !authorized {
// Block exists either from this instance against
// one or more directly involved actors, or between
// receiving account and one of those actors.
const text = "blocked"
return false, gtserror.NewErrorForbidden(errors.New(text), text)
}
// Copy existing URL + add request host and scheme.
inboxID := func() *url.URL {
u := new(url.URL)
*u = *r.URL
u.Host = r.Host
u.Scheme = scheme
return u
}()
// At this point we have everything we need, and have verified that
// the POST request is authentic (properly signed) and authorized
// (permitted to interact with the target inbox).
//
// Post the activity to the Actor's inbox and trigger side effects.
if err := f.sideEffectActor.PostInbox(ctx, inboxID, activity); err != nil {
// Check if it's a bad request because the
// object or target props weren't populated,
// or we failed parsing activity details.
//
// Log such activities to help debug, then
// return the rejection (400) to the peer.
if gtserror.IsMalformed(err) ||
errors.Is(err, pub.ErrObjectRequired) ||
errors.Is(err, pub.ErrTargetRequired) {
l = l.WithField("activity", activity)
l.Warnf("malformed incoming activity: %v", err)
const text = "malformed incoming activity"
return false, gtserror.NewErrorBadRequest(errors.New(text), text)
}
// Check if a function in the federatingDB
// has returned an explicit errWithCode for us.
if errWithCode, ok := err.(gtserror.WithCode); ok {
return false, errWithCode
}
// Default: there's been some real error.
err := gtserror.Newf("error calling sideEffectActor.PostInbox: %w", err)
return false, gtserror.NewErrorInternalError(err)
}
// Side effects are complete. Now delegate determining whether
// to do inbox forwarding, as well as the action to do it.
if err := f.sideEffectActor.InboxForwarding(ctx, inboxID, activity); err != nil {
// As a not-ideal side-effect, InboxForwarding will try
// to create entries if the federatingDB returns `false`
// when calling `Exists()` to determine whether the Activity
// is in the database.
//
// Since our `Exists()` function currently *always*
// returns false, it will *always* attempt to parse
// out and insert the Activity, trying to fetch other
// items from the DB in the process, which may or may
// not exist yet. Therefore, we should expect some
// errors coming from this function, and only warn log
// on certain ones.
//
// This check may be removed when the `Exists()` func
// is updated, and/or federating callbacks are handled
// properly.
if !errorsv2.IsV2(
err,
db.ErrAlreadyExists,
db.ErrNoEntries,
) {
// Failed inbox forwarding is not a show-stopper,
// and doesn't even necessarily denote a real error.
l.Warnf("error calling sideEffectActor.InboxForwarding: %v", err)
}
}
// Request is now undergoing processing. Caller
// of this function will handle writing Accepted.
return true, nil
}
/*
Functions below are just lightly wrapped versions
of the original go-fed federatingActor functions.
*/
func (f *federatingActor) PostInbox(c context.Context, w http.ResponseWriter, r *http.Request) (bool, error) {
return f.PostInboxScheme(c, w, r, "https")
}
func (f *federatingActor) Send(c context.Context, outbox *url.URL, t vocab.Type) (pub.Activity, error) {
log.Infof(c, "send activity %s via outbox %s", t.GetTypeName(), outbox)
return f.wrapped.Send(c, outbox, t)
}
func (f *federatingActor) GetInbox(c context.Context, w http.ResponseWriter, r *http.Request) (bool, error) {
return f.wrapped.GetInbox(c, w, r)
}
func (f *federatingActor) PostOutbox(c context.Context, w http.ResponseWriter, r *http.Request) (bool, error) {
return f.wrapped.PostOutbox(c, w, r)
}
func (f *federatingActor) PostOutboxScheme(c context.Context, w http.ResponseWriter, r *http.Request, scheme string) (bool, error) {
return f.wrapped.PostOutboxScheme(c, w, r, scheme)
}
func (f *federatingActor) GetOutbox(c context.Context, w http.ResponseWriter, r *http.Request) (bool, error) {
return f.wrapped.GetOutbox(c, w, r)
}
|