diff --git a/internal/api/activitypub.go b/internal/api/activitypub.go index 0a8a05d0f..a65f57105 100644 --- a/internal/api/activitypub.go +++ b/internal/api/activitypub.go @@ -21,6 +21,7 @@ import ( "code.superseriousbusiness.org/gotosocial/internal/api/activitypub/emoji" "code.superseriousbusiness.org/gotosocial/internal/api/activitypub/publickey" "code.superseriousbusiness.org/gotosocial/internal/api/activitypub/users" + "code.superseriousbusiness.org/gotosocial/internal/config" "code.superseriousbusiness.org/gotosocial/internal/db" "code.superseriousbusiness.org/gotosocial/internal/middleware" "code.superseriousbusiness.org/gotosocial/internal/processing" @@ -40,14 +41,22 @@ func (a *ActivityPub) Route(r *router.Router, m ...gin.HandlerFunc) { emojiGroup := r.AttachGroup("emoji") usersGroup := r.AttachGroup("users") + emojiGroup.Use(m...) + usersGroup.Use(m...) + // attach shared, non-global middlewares to both of these groups ccMiddleware := middleware.CacheControl(middleware.CacheControlConfig{ Directives: []string{"no-store"}, }) - emojiGroup.Use(m...) - usersGroup.Use(m...) - emojiGroup.Use(a.signatureCheckMiddleware, ccMiddleware) - usersGroup.Use(a.signatureCheckMiddleware, ccMiddleware) + + emojiGroup.Use(ccMiddleware, a.signatureCheckMiddleware) + usersGroup.Use(ccMiddleware) + + // hook the instance actor route first so we don't require auth + usersGroup.GET(config.InstanceActor(), a.users.InstanceActorGETHandler) + + // add signature checking to any other users routes + usersGroup.Use(a.signatureCheckMiddleware) a.emoji.Route(emojiGroup.Handle) a.users.Route(usersGroup.Handle) diff --git a/internal/api/activitypub/users/userget.go b/internal/api/activitypub/users/userget.go index 00d8a0f1f..2f9b6ed37 100644 --- a/internal/api/activitypub/users/userget.go +++ b/internal/api/activitypub/users/userget.go @@ -23,6 +23,7 @@ import ( "strings" apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util" + "code.superseriousbusiness.org/gotosocial/internal/config" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "github.com/gin-gonic/gin" ) @@ -65,3 +66,19 @@ func (m *Module) UsersGETHandler(c *gin.Context) { apiutil.JSONType(c, http.StatusOK, contentType, resp) } + +func (m *Module) InstanceActorGETHandler(c *gin.Context) { + contentType, err := apiutil.NegotiateAccept(c, apiutil.ActivityPubHeaders...) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + resp, errWithCode := m.processor.Fedi().UserGet(c.Request.Context(), config.InstanceActor(), c.Request.URL) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSONType(c, http.StatusOK, contentType, resp) +} diff --git a/internal/api/activitypub/users/userget_test.go b/internal/api/activitypub/users/userget_test.go index a9cba468f..0dbaa3652 100644 --- a/internal/api/activitypub/users/userget_test.go +++ b/internal/api/activitypub/users/userget_test.go @@ -19,6 +19,7 @@ package users_test import ( "encoding/json" + "io" "io/ioutil" "net/http" "net/http/httptest" @@ -158,6 +159,70 @@ func (suite *UserGetTestSuite) TestGetUserPublicKeyDeleted() { suite.EqualValues(targetAccount.Username, a.Username) } +func (suite *UserGetTestSuite) TestGetUserAuth() { + targetAccount := suite.testAccounts["local_account_1"] + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodGet, targetAccount.URI, nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/activity+json") + + // we need to pass the context through signature check first to set appropriate values on it + suite.signatureCheck(ctx) + + // normally the router would populate these params from the path values, + // but because we're calling the function directly, we need to set them manually. + ctx.Params = gin.Params{ + gin.Param{ + Key: users.UsernameKey, + Value: targetAccount.Username, + }, + } + + // trigger the function being tested + suite.userModule.UsersGETHandler(ctx) + + // check response + suite.EqualValues(http.StatusUnauthorized, recorder.Code) +} + +func (suite *UserGetTestSuite) TestInstanceActor() { + targetAccount := suite.testAccounts["instance_account"] + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := testrig.CreateGinTestContext(recorder, nil) + ctx.Request = httptest.NewRequest(http.MethodGet, targetAccount.URI, nil) // the endpoint we're hitting + ctx.Request.Header.Set("accept", "application/activity+json") + + // trigger the function being tested + suite.userModule.InstanceActorGETHandler(ctx) + + // check response + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + + b, err := io.ReadAll(result.Body) + suite.NoError(err) + + // should be a Service + m := make(map[string]interface{}) + err = json.Unmarshal(b, &m) + suite.NoError(err) + + t, err := streams.ToType(suite.T().Context(), m) + suite.NoError(err) + + // ensure it's a Service + svc, ok := t.(vocab.ActivityStreamsService) + suite.True(ok) + + suite.Equal(targetAccount.Username, svc.GetActivityStreamsPreferredUsername().GetIRI().String()) +} + func TestUserGetTestSuite(t *testing.T) { suite.Run(t, new(UserGetTestSuite)) } diff --git a/internal/config/util.go b/internal/config/util.go index 47e808f16..32734a8a9 100644 --- a/internal/config/util.go +++ b/internal/config/util.go @@ -72,3 +72,12 @@ func mapGet(m map[string]any, keys ...string) (any, bool) { } return nil, false } + +func InstanceActor() string { + a := GetHost() + if a == "" { + a = GetAccountDomain() + } + + return a +} diff --git a/internal/processing/fedi/user.go b/internal/processing/fedi/user.go index 53dfd6022..7d2cf5547 100644 --- a/internal/processing/fedi/user.go +++ b/internal/processing/fedi/user.go @@ -24,6 +24,7 @@ import ( "net/url" "code.superseriousbusiness.org/gotosocial/internal/ap" + "code.superseriousbusiness.org/gotosocial/internal/config" "code.superseriousbusiness.org/gotosocial/internal/db" "code.superseriousbusiness.org/gotosocial/internal/gtserror" "code.superseriousbusiness.org/gotosocial/internal/uris" @@ -46,6 +47,15 @@ func (p *Processor) UserGet(ctx context.Context, requestedUsername string, reque return nil, gtserror.NewErrorInternalError(err) } + if requestedUsername == config.InstanceActor() && uris.IsInstanceActorPath(requestURL) { + accountable, err := p.converter.AccountToAS(ctx, receiver) + if err != nil { + err := gtserror.Newf("error converting account: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + return data(accountable) + } + if uris.IsPublicKeyPath(requestURL) { // If request is on a public key path, we don't need to // authenticate this request. However, we'll only serve diff --git a/internal/uris/uri.go b/internal/uris/uri.go index d4bc2d829..75dc19d5a 100644 --- a/internal/uris/uri.go +++ b/internal/uris/uri.go @@ -20,6 +20,7 @@ package uris import ( "fmt" "net/url" + "path" "strings" "code.superseriousbusiness.org/gotosocial/internal/config" @@ -325,6 +326,11 @@ func IsPublicKeyPath(id *url.URL) bool { return regexes.PublicKeyPath.MatchString(id.Path) } +// IsInstanceActorPath returns true if the given URL corresponds to /users/instance_actor +func IsInstanceActorPath(u *url.URL) bool { + return u.Path == path.Join("/", "users", config.InstanceActor()) +} + // IsBlockPath returns true if the given URL path corresponds to eg /users/example_username/blocks/SOME_ULID_OF_A_BLOCK func IsBlockPath(id *url.URL) bool { return regexes.BlockPath.MatchString(id.Path)