From c081f2077679aabf1d744de20dca182d5cec8a70 Mon Sep 17 00:00:00 2001 From: Michael Jerger Date: Wed, 6 Aug 2025 16:16:13 +0200 Subject: [PATCH] Sent user activities to distant federated server (#8792) This PR is part of #4767. It contains * a refactoring of validation error messages * adds the ability to send user-activities to distant federated servers Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/8792 Reviewed-by: Earl Warren Co-authored-by: Michael Jerger Co-committed-by: Michael Jerger --- .deadcode-out | 19 +- .gitignore | 3 + modules/forgefed/activity_follow_test.go | 11 +- modules/forgefed/activity_like_test.go | 75 ++++---- modules/forgefed/activity_undo_like_test.go | 2 +- .../forgefed/activity_user_activity_test.go | 6 +- modules/forgefed/actor_person_test.go | 72 +++---- modules/forgefed/actor_test.go | 2 +- .../object_user_activity_note_test.go | 6 +- modules/validation/validatable.go | 6 +- routers/api/v1/activitypub/person.go | 162 ++++++++++++++++ routers/api/v1/api.go | 5 + routers/api/v1/swagger/activitypub.go | 7 + services/convert/activitypub_user_action.go | 177 ++++++++++++++++++ services/federation/person_inbox_accept.go | 22 +++ services/federation/person_inbox_create.go | 55 ++++++ services/federation/person_service.go | 35 ++++ services/federation/user_activity.go | 83 ++++++++ services/feed/action.go | 11 +- templates/swagger/v1_json.tmpl | 126 +++++++++++++ ...ivitypub_person_inbox_useractivity_test.go | 100 ++++++++++ .../api_activitypub_person_test.go | 30 ++- 22 files changed, 885 insertions(+), 130 deletions(-) create mode 100644 services/convert/activitypub_user_action.go create mode 100644 services/federation/person_inbox_accept.go create mode 100644 services/federation/person_inbox_create.go create mode 100644 services/federation/user_activity.go create mode 100644 tests/integration/api_activitypub_person_inbox_useractivity_test.go diff --git a/.deadcode-out b/.deadcode-out index 87dc416ff1..24facdf12e 100644 --- a/.deadcode-out +++ b/.deadcode-out @@ -13,13 +13,6 @@ forgejo.org/models IsErrSHANotFound IsErrMergeDivergingFastForwardOnly -forgejo.org/models/activities - GetActivityByID - NewFederatedUserActivity - CreateUserActivity - GetFollowingFeeds - FederatedUserActivity.loadActor - forgejo.org/models/auth WebAuthnCredentials @@ -63,8 +56,6 @@ forgejo.org/models/user GetUserAllSettings DeleteUserSetting GetFederatedUser - GetFederatedUserByUserID - GetFollowersForUser forgejo.org/modules/activitypub NewContext @@ -99,16 +90,10 @@ forgejo.org/modules/forgefed NewForgeUndoLike ForgeUndoLike.UnmarshalJSON ForgeUndoLike.Validate - NewForgeUserActivityFromAp - NewForgeUserActivity - ForgeUserActivity.Validate NewPersonIDFromModel GetItemByType JSONUnmarshalerFn NotEmpty - NewForgeUserActivityNoteFromAp - newNote - ForgeUserActivityNote.Validate ToRepository OnRepository @@ -236,8 +221,8 @@ forgejo.org/routers/web/org forgejo.org/services/context GetPrivateContext -forgejo.org/services/convert - ToActivityPubPersonFeedItem +forgejo.org/services/federation + FollowRemoteActor forgejo.org/services/repository IsErrForkAlreadyExist diff --git a/.gitignore b/.gitignore index 744e24a09a..e1200ce4e8 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,8 @@ _testmain.go *coverage.out coverage.all +coverage.html +coverage.html.gz coverage/ cpu.out @@ -129,3 +131,4 @@ prime/ # Manpage /man +tests/integration/api_activitypub_person_inbox_useractivity_test.go diff --git a/modules/forgefed/activity_follow_test.go b/modules/forgefed/activity_follow_test.go index bb0c1de2f7..8ba31d5f6f 100644 --- a/modules/forgefed/activity_follow_test.go +++ b/modules/forgefed/activity_follow_test.go @@ -9,6 +9,7 @@ import ( "forgejo.org/modules/validation" ap "github.com/go-ap/activitypub" + "github.com/stretchr/testify/assert" ) func Test_NewForgeFollowValidation(t *testing.T) { @@ -17,15 +18,13 @@ func Test_NewForgeFollowValidation(t *testing.T) { sut.Actor = ap.IRI("example.org/alice") sut.Object = ap.IRI("example.org/bob") - if err, _ := validation.IsValid(sut); !err { - t.Errorf("sut is invalid: %v\n", err) - } + valid, err := validation.IsValid(sut) + assert.True(t, valid, "sut is invalid: %v\n", err) sut = ForgeFollow{} sut.Actor = ap.IRI("example.org/alice") sut.Object = ap.IRI("example.org/bob") - if err, _ := validation.IsValid(sut); err { - t.Errorf("sut is valid: %v\n", err) - } + valid, err = validation.IsValid(sut) + assert.False(t, valid, "sut is valid: %v\n", err) } diff --git a/modules/forgefed/activity_like_test.go b/modules/forgefed/activity_like_test.go index 6b252d5960..eef5563d8b 100644 --- a/modules/forgefed/activity_like_test.go +++ b/modules/forgefed/activity_like_test.go @@ -13,6 +13,8 @@ import ( "forgejo.org/modules/validation" ap "github.com/go-ap/activitypub" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func Test_NewForgeLike(t *testing.T) { @@ -22,21 +24,14 @@ func Test_NewForgeLike(t *testing.T) { objectIRI := "https://codeberg.org/api/v1/activitypub/repository-id/1" startTime, _ := time.Parse("2006-Jan-02", "2024-Mar-07") sut, err := NewForgeLike(actorIRI, objectIRI, startTime) - if err != nil { - t.Errorf("unexpected error: %v\n", err) - } - if valid, _ := validation.IsValid(sut); !valid { - t.Errorf("sut expected to be valid: %v\n", sut.Validate()) - } + require.NoError(t, err, "unexpected error: %v\n", err) + + valid, _ := validation.IsValid(sut) + assert.True(t, valid, "sut expected to be valid: %v\n", sut.Validate()) got, err := sut.MarshalJSON() - if err != nil { - t.Errorf("MarshalJSON() error = \"%v\"", err) - return - } - if !reflect.DeepEqual(got, want) { - t.Errorf("MarshalJSON() got = %q, want %q", got, want) - } + require.NoError(t, err, "MarshalJSON() error = %q", err) + assert.True(t, reflect.DeepEqual(got, want), "MarshalJSON()\n got: %q,\n want: %q", got, want) } func Test_LikeMarshalJSON(t *testing.T) { @@ -66,13 +61,8 @@ func Test_LikeMarshalJSON(t *testing.T) { for name, tt := range tests { t.Run(name, func(t *testing.T) { got, err := tt.item.MarshalJSON() - if (err != nil || tt.wantErr != nil) && tt.wantErr.Error() != err.Error() { - t.Errorf("MarshalJSON() error = \"%v\", wantErr \"%v\"", err, tt.wantErr) - return - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("MarshalJSON() got = %q, want %q", got, tt.want) - } + assert.False(t, (err != nil || tt.wantErr != nil) && tt.wantErr.Error() != err.Error(), "MarshalJSON()\n got: %v,\n want: %v", err, tt.wantErr) + assert.True(t, reflect.DeepEqual(got, tt.want), "MarshalJSON()\n got: %q\n want: %q", got, tt.want) }) } } @@ -89,8 +79,8 @@ func Test_LikeUnmarshalJSON(t *testing.T) { item: []byte(`{"type":"Like","actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1","object":"https://codeberg.org/api/activitypub/repository-id/1"}`), want: &ForgeLike{ Activity: ap.Activity{ - Actor: ap.IRI("https://repo.prod.meissa.de/api/activitypub/user-id/1"), Type: "Like", + Actor: ap.IRI("https://repo.prod.meissa.de/api/activitypub/user-id/1"), Object: ap.IRI("https://codeberg.org/api/activitypub/repository-id/1"), }, }, @@ -107,12 +97,10 @@ func Test_LikeUnmarshalJSON(t *testing.T) { t.Run(name, func(t *testing.T) { got := new(ForgeLike) err := got.UnmarshalJSON(test.item) - if (err != nil || test.wantErr != nil) && !strings.Contains(err.Error(), test.wantErr.Error()) { - t.Errorf("UnmarshalJSON() error = \"%v\", wantErr \"%v\"", err, test.wantErr) - return - } + assert.False(t, (err != nil || test.wantErr != nil) && !strings.Contains(err.Error(), test.wantErr.Error()), "UnmarshalJSON()\n error: %v\n wantErr: %v", err, test.wantErr) + if !reflect.DeepEqual(got, test.want) { - t.Errorf("UnmarshalJSON() got = %q, want %q, err %q", got, test.want, err.Error()) + assert.Errorf(t, err, "UnmarshalJSON() got = %q, want %q, err %q", got, test.want, err.Error()) } }) } @@ -120,46 +108,47 @@ func Test_LikeUnmarshalJSON(t *testing.T) { func Test_ForgeLikeValidation(t *testing.T) { // Successful - sut := new(ForgeLike) sut.UnmarshalJSON([]byte(`{"type":"Like", "actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1", "object":"https://codeberg.org/api/activitypub/repository-id/1", "startTime": "2014-12-31T23:00:00-08:00"}`)) - if res, _ := validation.IsValid(sut); !res { - t.Errorf("sut expected to be valid: %v\n", sut.Validate()) - } + valid, _ := validation.IsValid(sut) + assert.True(t, valid, "sut expected to be valid: %v\n", sut.Validate()) // Errors - sut.UnmarshalJSON([]byte(`{"actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1", "object":"https://codeberg.org/api/activitypub/repository-id/1", "startTime": "2014-12-31T23:00:00-08:00"}`)) - if err := validateAndCheckError(sut, "type should not be empty"); err != nil { - t.Error(err) - } + validate := sut.Validate() + assert.Len(t, validate, 2) + assert.Equal(t, + "Field type contains the value , which is not in allowed subset [Like]", + validate[1]) sut.UnmarshalJSON([]byte(`{"type":"bad-type", "actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1", "object":"https://codeberg.org/api/activitypub/repository-id/1", "startTime": "2014-12-31T23:00:00-08:00"}`)) - if err := validateAndCheckError(sut, "Field type contains the value bad-type, which is not in allowed subset [Like]"); err != nil { - t.Error(err) - } + validate = sut.Validate() + assert.Len(t, validate, 1) + assert.Equal(t, + "Field type contains the value bad-type, which is not in allowed subset [Like]", + validate[0]) sut.UnmarshalJSON([]byte(`{"type":"Like", "actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1", "object":"https://codeberg.org/api/activitypub/repository-id/1", "startTime": "not a date"}`)) - if err := validateAndCheckError(sut, "StartTime was invalid."); err != nil { - t.Error(err) - } + validate = sut.Validate() + assert.Len(t, validate, 1) + assert.Equal(t, + "StartTime was invalid.", + validate[0]) } func TestActivityValidation_Attack(t *testing.T) { sut := new(ForgeLike) sut.UnmarshalJSON([]byte(`{rubbish}`)) - if len(sut.Validate()) != 5 { - t.Errorf("5 validation errors expected but was: %v\n", len(sut.Validate())) - } + assert.Len(t, sut.Validate(), 5) } diff --git a/modules/forgefed/activity_undo_like_test.go b/modules/forgefed/activity_undo_like_test.go index 5867a84e7b..76358b1669 100644 --- a/modules/forgefed/activity_undo_like_test.go +++ b/modules/forgefed/activity_undo_like_test.go @@ -173,7 +173,7 @@ func TestActivityValidationUndo(t *testing.T) { "startTime":"2024-03-27T00:00:00Z", "actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1", "object":"https://codeberg.org/api/v1/activitypub/repository-id/1"}}`)) - if err := validateAndCheckError(sut, "type should not be empty"); err != nil { + if err := validateAndCheckError(sut, "Value type should not be empty"); err != nil { t.Error(*err) } diff --git a/modules/forgefed/activity_user_activity_test.go b/modules/forgefed/activity_user_activity_test.go index 9cb9f133b9..107ae51204 100644 --- a/modules/forgefed/activity_user_activity_test.go +++ b/modules/forgefed/activity_user_activity_test.go @@ -9,6 +9,7 @@ import ( "forgejo.org/modules/validation" ap "github.com/go-ap/activitypub" + "github.com/stretchr/testify/assert" ) func Test_ForgeUserActivityValidation(t *testing.T) { @@ -34,7 +35,6 @@ func Test_ForgeUserActivityValidation(t *testing.T) { sut.Note = note - if res, _ := validation.IsValid(sut); !res { - t.Errorf("sut expected to be valid: %v\n", sut.Validate()) - } + valid, _ := validation.IsValid(sut) + assert.True(t, valid, "sut expected to be valid: %v\n", sut.Validate()) } diff --git a/modules/forgefed/actor_person_test.go b/modules/forgefed/actor_person_test.go index f466ddb964..e4f1734a9d 100644 --- a/modules/forgefed/actor_person_test.go +++ b/modules/forgefed/actor_person_test.go @@ -115,7 +115,7 @@ func TestPersonIdValidation(t *testing.T) { result, err := validation.IsValid(sut) assert.False(t, result) - require.EqualError(t, err, "Validation Error: forgefed.PersonID: path should not be empty\npath: \"\" has to be a person specific api path") + require.EqualError(t, err, "Validation Error: forgefed.PersonID: Value path should not be empty\npath: \"\" has to be a person specific api path") sut = PersonID{} sut.ID = "1" @@ -166,38 +166,28 @@ func TestWebfingerId(t *testing.T) { } func TestShouldThrowErrorOnInvalidInput(t *testing.T) { - var err any - _, err = NewPersonID("", "forgejo") - if err == nil { - t.Errorf("empty input should be invalid.") + tests := []struct { + input string + username string + expectErr bool + }{ + {"", "forgejo", true}, + {"http://localhost:3000/api/v1/something", "forgejo", true}, + {"./api/v1/something", "forgejo", true}, + {"http://1.2.3.4/api/v1/something", "forgejo", true}, + {"http:///[fe80::1ff:fe23:4567:890a%25eth0]/api/v1/something", "forgejo", true}, + {"https://codeberg.org/api/v1/activitypub/../activitypub/user-id/12345", "forgejo", true}, + {"https://myuser@an.other.host/api/v1/activitypub/user-id/1", "forgejo", true}, + {"https://an.other.host/api/v1/activitypub/user-id/1", "forgejo", false}, } - _, err = NewPersonID("http://localhost:3000/api/v1/something", "forgejo") - if err == nil { - t.Errorf("localhost uris are not external") - } - _, err = NewPersonID("./api/v1/something", "forgejo") - if err == nil { - t.Errorf("relative uris are not allowed") - } - _, err = NewPersonID("http://1.2.3.4/api/v1/something", "forgejo") - if err == nil { - t.Errorf("uri may not be ip-4 based") - } - _, err = NewPersonID("http:///[fe80::1ff:fe23:4567:890a%25eth0]/api/v1/something", "forgejo") - if err == nil { - t.Errorf("uri may not be ip-6 based") - } - _, err = NewPersonID("https://codeberg.org/api/v1/activitypub/../activitypub/user-id/12345", "forgejo") - if err == nil { - t.Errorf("uri may not contain relative path elements") - } - _, err = NewPersonID("https://myuser@an.other.host/api/v1/activitypub/user-id/1", "forgejo") - if err == nil { - t.Errorf("uri may not contain unparsed elements") - } - _, err = NewPersonID("https://an.other.host/api/v1/activitypub/user-id/1", "forgejo") - if err != nil { - t.Errorf("this uri should be valid but was: %v", err) + + for _, tt := range tests { + _, err := NewPersonID(tt.input, tt.username) + if tt.expectErr { + assert.Error(t, err, "Expected an error for input: %s", tt.input) + } else { + assert.NoError(t, err, "Expected no error for input: %s, but got: %v", tt.input, err) + } } } @@ -221,14 +211,11 @@ func Test_PersonUnmarshalJSON(t *testing.T) { } sut := new(ForgePerson) err := sut.UnmarshalJSON([]byte(`{"type":"Person","preferredUsername":"MaxMuster"}`)) - if err != nil { - t.Errorf("UnmarshalJSON() unexpected error: %v", err) - } + require.NoError(t, err, "UnmarshalJSON() unexpected error: %q", err) + x, _ := expected.MarshalJSON() y, _ := sut.MarshalJSON() - if !reflect.DeepEqual(x, y) { - t.Errorf("UnmarshalJSON() expected: %q got: %q", x, y) - } + assert.True(t, reflect.DeepEqual(x, y), "UnmarshalJSON()\n got: %q,\n want: %q", x, y) expectedStr := strings.ReplaceAll(strings.ReplaceAll(`{ "id":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/10", @@ -244,9 +231,7 @@ func Test_PersonUnmarshalJSON(t *testing.T) { "\n", ""), "\t", "") err = sut.UnmarshalJSON([]byte(expectedStr)) - if err != nil { - t.Errorf("UnmarshalJSON() unexpected error: %v", err) - } + require.NoError(t, err, "UnmarshalJSON() unexpected error: %q", err) result, _ := sut.MarshalJSON() assert.JSONEq(t, expectedStr, string(result), "Expected string is not equal") } @@ -254,9 +239,8 @@ func Test_PersonUnmarshalJSON(t *testing.T) { func TestForgePersonValidation(t *testing.T) { sut := new(ForgePerson) sut.UnmarshalJSON([]byte(`{"type":"Person","preferredUsername":"MaxMuster"}`)) - if res, _ := validation.IsValid(sut); !res { - t.Errorf("sut expected to be valid: %v\n", sut.Validate()) - } + valid, _ := validation.IsValid(sut) + assert.True(t, valid, "sut expected to be valid: %v\n", sut.Validate()) } func TestAsloginName(t *testing.T) { diff --git a/modules/forgefed/actor_test.go b/modules/forgefed/actor_test.go index 48d773c5b9..a32114616c 100644 --- a/modules/forgefed/actor_test.go +++ b/modules/forgefed/actor_test.go @@ -58,7 +58,7 @@ func TestActorIdValidation(t *testing.T) { sut.UnvalidatedInput = "https://an.other.host/api/v1/activitypub/user-id/" result := sut.Validate() assert.Len(t, result, 1) - assert.Equal(t, "ID should not be empty", result[0]) + assert.Equal(t, "Value ID should not be empty", result[0]) sut = ActorID{} sut.ID = "1" diff --git a/modules/forgefed/object_user_activity_note_test.go b/modules/forgefed/object_user_activity_note_test.go index 20c3666bb1..02aebd58d3 100644 --- a/modules/forgefed/object_user_activity_note_test.go +++ b/modules/forgefed/object_user_activity_note_test.go @@ -9,6 +9,7 @@ import ( "forgejo.org/modules/validation" ap "github.com/go-ap/activitypub" + "github.com/stretchr/testify/assert" ) func Test_UserActivityNoteValidation(t *testing.T) { @@ -22,7 +23,6 @@ func Test_UserActivityNoteValidation(t *testing.T) { } sut.URL = ap.IRI("example.org/user-id/57") - if res, _ := validation.IsValid(sut); !res { - t.Errorf("sut expected to be valid: %v\n", sut.Validate()) - } + valid, _ := validation.IsValid(sut) + assert.True(t, valid, "sut expected to be valid: %v\n", sut.Validate()) } diff --git a/modules/validation/validatable.go b/modules/validation/validatable.go index 4500f6e53d..7bcca03bf8 100644 --- a/modules/validation/validatable.go +++ b/modules/validation/validatable.go @@ -45,7 +45,7 @@ func IsValid(v Validateable) (bool, error) { func ValidateIDExists(value ap.Item, name string) []string { if value == nil { - return []string{fmt.Sprintf("%v should not be nil", name)} + return []string{fmt.Sprintf("Field %v must not be nil", name)} } return ValidateNotEmpty(value.GetID().String(), name) } @@ -76,12 +76,12 @@ func ValidateNotEmpty(value any, name string) []string { if isValid { return []string{} } - return []string{fmt.Sprintf("%v should not be empty", name)} + return []string{fmt.Sprintf("Value %v should not be empty", name)} } func ValidateMaxLen(value string, maxLen int, name string) []string { if utf8.RuneCountInString(value) > maxLen { - return []string{fmt.Sprintf("Value %v was longer than %v", name, maxLen)} + return []string{fmt.Sprintf("Value %v is longer than expected length %v", name, maxLen)} } return []string{} } diff --git a/routers/api/v1/activitypub/person.go b/routers/api/v1/activitypub/person.go index 6120a078af..72f9680b9b 100644 --- a/routers/api/v1/activitypub/person.go +++ b/routers/api/v1/activitypub/person.go @@ -6,9 +6,12 @@ package activitypub import ( "net/http" + "forgejo.org/models/activities" "forgejo.org/modules/activitypub" + "forgejo.org/modules/forgefed" "forgejo.org/modules/log" "forgejo.org/modules/web" + "forgejo.org/routers/api/v1/utils" "forgejo.org/services/context" "forgejo.org/services/convert" "forgejo.org/services/federation" @@ -78,3 +81,162 @@ func PersonInbox(ctx *context.APIContext) { } responseServiceResult(ctx, result) } + +// PersonFeed returns the recorded activities in the user's feed +func PersonFeed(ctx *context.APIContext) { + // swagger:operation GET /activitypub/user-id/{user-id}/outbox activitypub activitypubPersonFeed + // --- + // summary: List the user's recorded activity + // produces: + // - application/json + // parameters: + // - name: user-id + // in: path + // description: user ID of the user + // type: integer + // required: true + // responses: + // "200": + // "$ref": "#/responses/PersonFeed" + // "403": + // "$ref": "#/responses/forbidden" + + listOptions := utils.GetListOptions(ctx) + opts := activities.GetFollowingFeedsOptions{ + ListOptions: listOptions, + } + items, count, err := activities.GetFollowingFeeds(ctx, ctx.ContextUser.ID, opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetFollowingFeeds", err) + return + } + ctx.SetTotalCountHeader(count) + + feed := ap.OrderedCollectionNew(ap.IRI(ctx.ContextUser.APActorID() + "/outbox")) + feed.AttributedTo = ap.IRI(ctx.ContextUser.APActorID()) + for _, item := range items { + if err := feed.OrderedItems.Append(convert.ToActivityPubPersonFeedItem(item)); err != nil { + ctx.Error(http.StatusInternalServerError, "OrderedItems.Append", err) + return + } + } + + binary, err := jsonld.WithContext(jsonld.IRI(ap.ActivityBaseURI), jsonld.IRI(ap.SecurityContextURI)).Marshal(feed) + if err != nil { + ctx.ServerError("MarshalJSON", err) + return + } + + ctx.Resp.Header().Add("Content-Type", activitypub.ActivityStreamsContentType) + ctx.Resp.WriteHeader(http.StatusOK) + if _, err = ctx.Resp.Write(binary); err != nil { + log.Error("write to resp err: %v", err) + } +} + +func getActivity(ctx *context.APIContext, id int64) (*forgefed.ForgeUserActivity, error) { + action, err := activities.GetActivityByID(ctx, id) + if err != nil { + ctx.Error(http.StatusInternalServerError, "GetActivityByID", err.Error()) + return nil, err + } + + if action.UserID != action.ActUserID || action.ActUserID != ctx.ContextUser.ID { + ctx.NotFound() + return nil, err + } + + actions := activities.ActionList{action} + if err := actions.LoadAttributes(ctx); err != nil { + ctx.Error(http.StatusInternalServerError, "action.LoadAttributes", err.Error()) + return nil, err + } + + activity, err := convert.ActionToForgeUserActivity(ctx, actions[0]) + if err != nil { + ctx.Error(http.StatusInternalServerError, "ActionToForgeUserActivity", err.Error()) + return nil, err + } + + return &activity, nil +} + +// PersonActivity returns a user's given activity +func PersonActivity(ctx *context.APIContext) { + // swagger:operation GET /activitypub/user-id/{user-id}/activities/{activity-id}/activity activitypub activitypubPersonActivity + // --- + // summary: Get a specific activity of the user + // produces: + // - application/json + // parameters: + // - name: user-id + // in: path + // description: user ID of the user + // type: integer + // required: true + // - name: activity-id + // in: path + // description: activity ID of the sought activity + // type: integer + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActivityPub" + + id := ctx.ParamsInt64("activity-id") + activity, err := getActivity(ctx, id) + if err != nil { + return + } + + binary, err := jsonld.WithContext(jsonld.IRI(ap.ActivityBaseURI), jsonld.IRI(ap.SecurityContextURI)).Marshal(activity) + if err != nil { + ctx.ServerError("MarshalJSON", err) + return + } + ctx.Resp.Header().Add("Content-Type", activitypub.ActivityStreamsContentType) + ctx.Resp.WriteHeader(http.StatusOK) + if _, err = ctx.Resp.Write(binary); err != nil { + log.Error("write to resp err: %v", err) + } +} + +// PersonActivity returns the Object part of a user's given activity +func PersonActivityNote(ctx *context.APIContext) { + // swagger:operation GET /activitypub/user-id/{user-id}/activities/{activity-id} activitypub activitypubPersonActivityNote + // --- + // summary: Get a specific activity object of the user + // produces: + // - application/json + // parameters: + // - name: user-id + // in: path + // description: user ID of the user + // type: integer + // required: true + // - name: activity-id + // in: path + // description: activity ID of the sought activity + // type: integer + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActivityPub" + + id := ctx.ParamsInt64("activity-id") + activity, err := getActivity(ctx, id) + if err != nil { + return + } + + binary, err := jsonld.WithContext(jsonld.IRI(ap.ActivityBaseURI), jsonld.IRI(ap.SecurityContextURI)).Marshal(activity.Object) + if err != nil { + ctx.ServerError("MarshalJSON", err) + return + } + ctx.Resp.Header().Add("Content-Type", activitypub.ActivityStreamsContentType) + ctx.Resp.WriteHeader(http.StatusOK) + if _, err = ctx.Resp.Write(binary); err != nil { + log.Error("write to resp err: %v", err) + } +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 2f806ba35d..26a2c0ffe3 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -844,6 +844,11 @@ func Routes() *web.Route { activitypub.ReqHTTPUserSignature(), bind(ap.Activity{}), activitypub.PersonInbox) + m.Group("/activities/{activity-id}", func() { + m.Get("", activitypub.PersonActivityNote) + m.Get("/activity", activitypub.PersonActivity) + }) + m.Get("/outbox", activitypub.ReqHTTPUserSignature(), activitypub.PersonFeed) }, context.UserIDAssignmentAPI(), checkTokenPublicOnly()) m.Group("/actor", func() { m.Get("", activitypub.Actor) diff --git a/routers/api/v1/swagger/activitypub.go b/routers/api/v1/swagger/activitypub.go index 6235009572..a11fc4098c 100644 --- a/routers/api/v1/swagger/activitypub.go +++ b/routers/api/v1/swagger/activitypub.go @@ -13,3 +13,10 @@ type swaggerResponseActivityPub struct { // in:body Body api.ActivityPub `json:"body"` } + +// Personfeed +// swagger:response PersonFeed +type swaggerResponsePersonFeed struct { + // in:body + Body []api.APPersonFollowItem `json:"body"` +} diff --git a/services/convert/activitypub_user_action.go b/services/convert/activitypub_user_action.go new file mode 100644 index 0000000000..9c62e6f25c --- /dev/null +++ b/services/convert/activitypub_user_action.go @@ -0,0 +1,177 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package convert + +import ( + "context" + "fmt" + "html" + "net/url" + "time" + + activities_model "forgejo.org/models/activities" + issues_model "forgejo.org/models/issues" + fm "forgejo.org/modules/forgefed" + "forgejo.org/modules/json" + "forgejo.org/modules/markup" + "forgejo.org/modules/markup/markdown" +) + +func ActionToForgeUserActivity(ctx context.Context, action *activities_model.Action) (fm.ForgeUserActivity, error) { + render := func(format string, args ...any) string { + return fmt.Sprintf(`%s %s`, action.ActUser.HTMLURL(), action.GetActDisplayName(ctx), fmt.Sprintf(format, args...)) + } + renderIssue := func(issue *issues_model.Issue) string { + return fmt.Sprintf(`%s#%d`, + issue.HTMLURL(), + action.GetRepoPath(ctx), + issue.Index, + ) + } + renderRepo := func() string { + return fmt.Sprintf(`%s`, action.Repo.HTMLURL(), action.GetRepoPath(ctx)) + } + renderBranch := func() string { + return fmt.Sprintf(`%s`, action.GetRefLink(ctx), action.GetBranch()) + } + renderTag := func() string { + return fmt.Sprintf(`%s`, action.GetRefLink(ctx), action.GetTag()) + } + + makeUserActivity := func(format string, args ...any) (fm.ForgeUserActivity, error) { + return fm.NewForgeUserActivity(action.ActUser, action.ID, render(format, args...)) + } + + switch action.OpType { + case activities_model.ActionCreateRepo: + return makeUserActivity("created a new repository: %s", renderRepo()) + case activities_model.ActionRenameRepo: + return makeUserActivity("renamed a repository: %s", renderRepo()) + case activities_model.ActionStarRepo: + return makeUserActivity("starred a repository: %s", renderRepo()) + case activities_model.ActionWatchRepo: + return makeUserActivity("started watching a repository: %s", renderRepo()) + case activities_model.ActionCommitRepo: + type PushCommit struct { + Sha1 string + Message string + AuthorEmail string + AuthorName string + CommitterEmail string + CommitterName string + Timestamp time.Time + } + type PushCommits struct { + Commits []*PushCommit + HeadCommit *PushCommit + CompareURL string + Len int + } + + commits := &PushCommits{} + if err := json.Unmarshal([]byte(action.GetContent()), commits); err != nil { + return fm.ForgeUserActivity{}, err + } + commitsHTML := "" + renderCommit := func(commit *PushCommit) string { + return fmt.Sprintf(`
  • %s
    %s
  • `, + fmt.Sprintf("%s/commit/%s", action.GetRepoAbsoluteLink(ctx), url.PathEscape(commit.Sha1)), + commit.Sha1, + html.EscapeString(commit.Message), + ) + } + for _, commit := range commits.Commits { + commitsHTML += renderCommit(commit) + } + return makeUserActivity("pushed to %s at %s:
      %s
    ", renderBranch(), renderRepo(), commitsHTML) + case activities_model.ActionCreateIssue: + if err := action.LoadIssue(ctx); err != nil { + return fm.ForgeUserActivity{}, err + } + return makeUserActivity("opened issue %s", renderIssue(action.Issue)) + case activities_model.ActionCreatePullRequest: + if err := action.LoadIssue(ctx); err != nil { + return fm.ForgeUserActivity{}, err + } + return makeUserActivity("opened pull request %s", renderIssue(action.Issue)) + case activities_model.ActionTransferRepo: + return makeUserActivity("transferred %s", renderRepo()) + case activities_model.ActionPushTag: + return makeUserActivity("pushed %s at %s", renderTag(), renderRepo()) + case activities_model.ActionCommentIssue: + renderedComment, err := markdown.RenderString(&markup.RenderContext{ + Ctx: ctx, + }, action.Comment.Content) + if err != nil { + return fm.ForgeUserActivity{}, err + } + + return makeUserActivity(`commented on %s:
    %s
    `, + action.GetCommentHTMLURL(ctx), + renderIssue(action.Comment.Issue), + renderedComment, + ) + case activities_model.ActionMergePullRequest: + if err := action.LoadIssue(ctx); err != nil { + return fm.ForgeUserActivity{}, err + } + return makeUserActivity("merged pull request %s", renderIssue(action.Issue)) + case activities_model.ActionCloseIssue: + if err := action.LoadIssue(ctx); err != nil { + return fm.ForgeUserActivity{}, err + } + return makeUserActivity("closed issue %s", renderIssue(action.Issue)) + case activities_model.ActionReopenIssue: + if err := action.LoadIssue(ctx); err != nil { + return fm.ForgeUserActivity{}, err + } + return makeUserActivity("reopened issue %s", renderIssue(action.Issue)) + case activities_model.ActionClosePullRequest: + if err := action.LoadIssue(ctx); err != nil { + return fm.ForgeUserActivity{}, err + } + return makeUserActivity("closed pull request %s", renderIssue(action.Issue)) + case activities_model.ActionReopenPullRequest: + if err := action.LoadIssue(ctx); err != nil { + return fm.ForgeUserActivity{}, err + } + return makeUserActivity("reopened pull request %s", renderIssue(action.Issue)) + case activities_model.ActionDeleteTag: + return makeUserActivity("deleted tag %s at %s", action.GetTag(), renderRepo()) + case activities_model.ActionDeleteBranch: + return makeUserActivity("deleted branch %s at %s", action.GetBranch(), renderRepo()) + case activities_model.ActionApprovePullRequest: + if err := action.LoadIssue(ctx); err != nil { + return fm.ForgeUserActivity{}, err + } + return makeUserActivity("approved pull request %s", renderIssue(action.Issue)) + case activities_model.ActionRejectPullRequest: + if err := action.LoadIssue(ctx); err != nil { + return fm.ForgeUserActivity{}, err + } + return makeUserActivity("rejected pull request %s", renderIssue(action.Issue)) + case activities_model.ActionCommentPull: + renderedComment, err := markdown.RenderString(&markup.RenderContext{ + Ctx: ctx, + }, action.Comment.Content) + if err != nil { + return fm.ForgeUserActivity{}, err + } + + return makeUserActivity(`commented on %s:
    %s
    `, + action.GetCommentHTMLURL(ctx), + renderIssue(action.Comment.Issue), + renderedComment, + ) + case activities_model.ActionMirrorSyncPush: + case activities_model.ActionMirrorSyncCreate: + case activities_model.ActionMirrorSyncDelete: + case activities_model.ActionPublishRelease: + case activities_model.ActionPullReviewDismissed: + case activities_model.ActionPullRequestReadyForReview: + case activities_model.ActionAutoMergePullRequest: + } + + return makeUserActivity("performed an unrecognised action: %s", action.OpType.String()) +} diff --git a/services/federation/person_inbox_accept.go b/services/federation/person_inbox_accept.go new file mode 100644 index 0000000000..d0a840bd2d --- /dev/null +++ b/services/federation/person_inbox_accept.go @@ -0,0 +1,22 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package federation + +import ( + "net/http" + + "forgejo.org/modules/log" + + ap "github.com/go-ap/activitypub" +) + +func processPersonInboxAccept(activity *ap.Activity) (ServiceResult, error) { + if activity.Object.GetType() != ap.FollowType { + log.Error("Invalid object type for Accept activity: %v", activity.Object.GetType()) + return ServiceResult{}, NewErrNotAcceptablef("invalid object type for Accept activity: %v", activity.Object.GetType()) + } + + // We currently do not do anything here, we just drop it. + return NewServiceResultStatusOnly(http.StatusNoContent), nil +} diff --git a/services/federation/person_inbox_create.go b/services/federation/person_inbox_create.go new file mode 100644 index 0000000000..2132c7ede1 --- /dev/null +++ b/services/federation/person_inbox_create.go @@ -0,0 +1,55 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package federation + +import ( + "context" + "net/http" + + "forgejo.org/models/activities" + "forgejo.org/models/user" + fm "forgejo.org/modules/forgefed" + "forgejo.org/modules/log" + + ap "github.com/go-ap/activitypub" +) + +func processPersonInboxCreate(ctx context.Context, user *user.User, activity *ap.Activity) (ServiceResult, error) { + createAct, err := fm.NewForgeUserActivityFromAp(*activity) + if err != nil { + log.Error("Invalid user activity: %v, %v", activity, err) + return ServiceResult{}, NewErrNotAcceptablef("Invalid user activity: %v", err) + } + + actorURI := createAct.Actor.GetLink().String() + federatedBaseUser, _, _, err := findFederatedUser(ctx, actorURI) + if err != nil { + log.Error("Federated user not found (%s): %v", actorURI, err) + return ServiceResult{}, NewErrNotAcceptablef("federated user not found (%s): %v", actorURI, err) + } + if federatedBaseUser == nil { + log.Error("Federated user not found (%s): %v", actorURI, err) + return ServiceResult{}, NewErrNotAcceptablef("federated user not found (%s): %v", actorURI, err) + } + + federatedUserActivity, err := activities.NewFederatedUserActivity( + user.ID, + federatedBaseUser.ID, + createAct.Actor.GetLink().String(), + createAct.Note.Content.String(), + createAct.Note.URL.GetID().String(), + *activity, + ) + if err != nil { + log.Error("Error creating federatedUserActivity (%s): %v", actorURI, err) + return ServiceResult{}, NewErrNotAcceptablef("Error creating federatedUserActivity: %v", err) + } + + if err := activities.CreateUserActivity(ctx, &federatedUserActivity); err != nil { + log.Error("Unable to record activity: %v", err) + return ServiceResult{}, NewErrNotAcceptablef("Unable to record activity: %v", err) + } + + return NewServiceResultStatusOnly(http.StatusNoContent), nil +} diff --git a/services/federation/person_service.go b/services/federation/person_service.go index f67d2b492d..d6482d013c 100644 --- a/services/federation/person_service.go +++ b/services/federation/person_service.go @@ -5,21 +5,56 @@ package federation import ( "context" + "net/http" "forgejo.org/models/user" + "forgejo.org/modules/forgefed" "forgejo.org/modules/log" + context_service "forgejo.org/services/context" ap "github.com/go-ap/activitypub" + "github.com/go-ap/jsonld" ) func ProcessPersonInbox(ctx context.Context, user *user.User, activity *ap.Activity) (ServiceResult, error) { switch activity.Type { + case ap.CreateType: + return processPersonInboxCreate(ctx, user, activity) case ap.FollowType: return processPersonFollow(ctx, user, activity) case ap.UndoType: return processPersonInboxUndo(ctx, user, activity) + case ap.AcceptType: + return processPersonInboxAccept(activity) } log.Error("Unsupported PersonInbox activity: %v", activity.Type) return ServiceResult{}, NewErrNotAcceptablef("unsupported activity: %v", activity.Type) } + +func FollowRemoteActor(ctx *context_service.APIContext, localUser *user.User, actorURI string) error { + _, federatedUser, federationHost, err := FindOrCreateFederatedUser(ctx.Base, actorURI) + if err != nil { + log.Error("Federated user not found (%s): %v", actorURI, err) + ctx.Error(http.StatusNotAcceptable, "Federated user not found", err) + return err + } + + followReq, err := forgefed.NewForgeFollow(localUser.APActorID(), actorURI) + if err != nil { + return err + } + + payload, err := jsonld.WithContext(jsonld.IRI(ap.ActivityBaseURI)). + Marshal(followReq) + if err != nil { + return err + } + + hostURL := federationHost.AsURL() + return deliveryQueue.Push(deliveryQueueItem{ + InboxURL: hostURL.JoinPath(federatedUser.InboxPath).String(), + Doer: localUser, + Payload: payload, + }) +} diff --git a/services/federation/user_activity.go b/services/federation/user_activity.go new file mode 100644 index 0000000000..0db2aee4ec --- /dev/null +++ b/services/federation/user_activity.go @@ -0,0 +1,83 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package federation + +import ( + "context" + + activities_model "forgejo.org/models/activities" + "forgejo.org/models/forgefed" + "forgejo.org/models/user" + "forgejo.org/modules/setting" + "forgejo.org/modules/structs" + "forgejo.org/services/convert" + + ap "github.com/go-ap/activitypub" + "github.com/go-ap/jsonld" +) + +func SendUserActivity(ctx context.Context, doer *user.User, activity *activities_model.Action) error { + followers, err := user.GetFollowersForUser(ctx, doer) + if err != nil { + return err + } + + userActivity, err := convert.ActionToForgeUserActivity(ctx, activity) + if err != nil { + return err + } + + payload, err := jsonld.WithContext( + jsonld.IRI(ap.ActivityBaseURI), + ).Marshal(userActivity) + if err != nil { + return err + } + + for _, follower := range followers { + _, federatedUserFollower, err := user.GetFederatedUserByUserID(ctx, follower.FollowingUserID) + if err != nil { + return err + } + + federationHost, err := forgefed.GetFederationHost(ctx, federatedUserFollower.FederationHostID) + if err != nil { + return err + } + + hostURL := federationHost.AsURL() + if err := deliveryQueue.Push(deliveryQueueItem{ + InboxURL: hostURL.JoinPath(federatedUserFollower.InboxPath).String(), + Doer: doer, + Payload: payload, + }); err != nil { + return err + } + } + + return nil +} + +func NotifyActivityPubFollowers(ctx context.Context, actions []activities_model.Action) error { + if !setting.Federation.Enabled { + return nil + } + for _, act := range actions { + if act.Repo != nil { + if act.Repo.IsPrivate { + continue + } + if act.Repo.Owner.KeepActivityPrivate || act.Repo.Owner.Visibility != structs.VisibleTypePublic { + continue + } + } + if act.ActUser.KeepActivityPrivate || act.ActUser.Visibility != structs.VisibleTypePublic { + continue + } + if err := SendUserActivity(ctx, act.ActUser, &act); err != nil { + return err + } + } + return nil +} diff --git a/services/feed/action.go b/services/feed/action.go index 7d179bd1c8..c708ae5404 100644 --- a/services/feed/action.go +++ b/services/feed/action.go @@ -19,6 +19,7 @@ import ( "forgejo.org/modules/repository" "forgejo.org/modules/setting" "forgejo.org/modules/util" + federation_service "forgejo.org/services/federation" notify_service "forgejo.org/services/notify" ) @@ -40,21 +41,19 @@ func NewNotifier() notify_service.Notifier { } func notifyAll(ctx context.Context, action *activities_model.Action) error { - _, err := activities_model.NotifyWatchers(ctx, action) + out, err := activities_model.NotifyWatchers(ctx, action) if err != nil { return err } - return err - // return federation_service.NotifyActivityPubFollowers(ctx, out) + return federation_service.NotifyActivityPubFollowers(ctx, out) } func notifyAllActions(ctx context.Context, acts []*activities_model.Action) error { - _, err := activities_model.NotifyWatchersActions(ctx, acts) + out, err := activities_model.NotifyWatchersActions(ctx, acts) if err != nil { return err } - return nil - // return federation_service.NotifyActivityPubFollowers(ctx, out) + return federation_service.NotifyActivityPubFollowers(ctx, out) } func (a *actionNotifier) NewIssue(ctx context.Context, issue *issues_model.Issue, mentions []*user_model.User) { diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 492f9487f3..353abe76c0 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -142,6 +142,72 @@ } } }, + "/activitypub/user-id/{user-id}/activities/{activity-id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "activitypub" + ], + "summary": "Get a specific activity object of the user", + "operationId": "activitypubPersonActivityNote", + "parameters": [ + { + "type": "integer", + "description": "user ID of the user", + "name": "user-id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "activity ID of the sought activity", + "name": "activity-id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ActivityPub" + } + } + } + }, + "/activitypub/user-id/{user-id}/activities/{activity-id}/activity": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "activitypub" + ], + "summary": "Get a specific activity of the user", + "operationId": "activitypubPersonActivity", + "parameters": [ + { + "type": "integer", + "description": "user ID of the user", + "name": "user-id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "activity ID of the sought activity", + "name": "activity-id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ActivityPub" + } + } + } + }, "/activitypub/user-id/{user-id}/inbox": { "post": { "produces": [ @@ -168,6 +234,35 @@ } } }, + "/activitypub/user-id/{user-id}/outbox": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "activitypub" + ], + "summary": "List the user's recorded activity", + "operationId": "activitypubPersonFeed", + "parameters": [ + { + "type": "integer", + "description": "user ID of the user", + "name": "user-id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/PersonFeed" + }, + "403": { + "$ref": "#/responses/forbidden" + } + } + } + }, "/admin/cron": { "get": { "produces": [ @@ -21121,6 +21216,28 @@ }, "x-go-package": "forgejo.org/services/context" }, + "APPersonFollowItem": { + "type": "object", + "properties": { + "actor_id": { + "type": "string", + "x-go-name": "ActorID" + }, + "note": { + "type": "string", + "x-go-name": "Note" + }, + "original_item": { + "type": "string", + "x-go-name": "OriginalItem" + }, + "original_url": { + "type": "string", + "x-go-name": "OriginalURL" + } + }, + "x-go-package": "forgejo.org/modules/structs" + }, "AccessToken": { "type": "object", "title": "AccessToken represents an API access token.", @@ -29566,6 +29683,15 @@ } } }, + "PersonFeed": { + "description": "Personfeed", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/APPersonFollowItem" + } + } + }, "PublicKey": { "description": "PublicKey", "schema": { diff --git a/tests/integration/api_activitypub_person_inbox_useractivity_test.go b/tests/integration/api_activitypub_person_inbox_useractivity_test.go new file mode 100644 index 0000000000..39f08f4d9c --- /dev/null +++ b/tests/integration/api_activitypub_person_inbox_useractivity_test.go @@ -0,0 +1,100 @@ +// Copyright 2022, 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/url" + "testing" + "time" + + auth_model "forgejo.org/models/auth" + "forgejo.org/models/unittest" + user_model "forgejo.org/models/user" + "forgejo.org/modules/activitypub" + "forgejo.org/modules/setting" + "forgejo.org/modules/structs" + "forgejo.org/modules/test" + "forgejo.org/routers" + "forgejo.org/services/contexttest" + "forgejo.org/services/federation" + "forgejo.org/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestActivityPubPersonInboxNoteToDistant(t *testing.T) { + defer test.MockVariableValue(&setting.Federation.Enabled, true)() + defer test.MockVariableValue(&setting.Federation.SignatureEnforced, false)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + federation.Init() + + mock := test.NewFederationServerMock() + federatedSrv := mock.DistantServer(t) + defer federatedSrv.Close() + + onGiteaRun(t, func(t *testing.T, localUrl *url.URL) { + defer test.MockVariableValue(&setting.AppURL, localUrl.String())() + + distantURL := federatedSrv.URL + distantUser15URL := fmt.Sprintf("%s/api/v1/activitypub/user-id/15", distantURL) + + localUser2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + localUser2URL := localUrl.JoinPath("/api/v1/activitypub/user-id/2").String() + localUser2Inbox := fmt.Sprintf("%v/inbox", localUser2URL) + localSession2 := loginUser(t, localUser2.LoginName) + localSecssion2Token := getTokenForLoggedInUser(t, localSession2, auth_model.AccessTokenScopeWriteIssue) + + repo, _, f := tests.CreateDeclarativeRepoWithOptions(t, localUser2, tests.DeclarativeRepoOptions{}) + defer f() + + // follow (distant follows local) + followActivity := []byte(fmt.Sprintf( + `{"type":"Follow",`+ + `"actor":"%s",`+ + `"object":"%s"}`, + distantUser15URL, + localUser2URL, + )) + ctx, _ := contexttest.MockAPIContext(t, localUser2Inbox) + cf, err := activitypub.NewClientFactoryWithTimeout(60 * time.Second) + require.NoError(t, err) + c, err := cf.WithKeysDirect(ctx, mock.ApActor.PrivKey, + mock.ApActor.KeyID(federatedSrv.URL)) + require.NoError(t, err) + resp, err := c.Post(followActivity, localUser2Inbox) + require.NoError(t, err) + assert.Equal(t, http.StatusAccepted, resp.StatusCode) + + // local action which triggers a user activity + IssueURL := fmt.Sprintf("/api/v1/repos/%s/issues?state=all", repo.FullName()) + req := NewRequestWithJSON(t, "POST", IssueURL, &structs.CreateIssueOption{ + Title: "ActivityFeed test", + Body: "Nothing to see here!", + }).AddTokenAuth(localSecssion2Token) + MakeRequest(t, req, http.StatusCreated) + + // distant request outbox + localUser2Outbox := fmt.Sprintf("%v/outbox", localUser2URL) + resp, err = c.Get(localUser2Outbox) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // distant request activity & activity note + localUser2ActivityNote := fmt.Sprintf("%v/activities/1", localUser2URL) + localUser2Activity := fmt.Sprintf("%v/activities/1/activity", localUser2URL) + resp, err = c.Get(localUser2ActivityNote) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + resp, err = c.Get(localUser2Activity) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // check for activity on distant inbox + assert.Contains(t, mock.LastPost, "user2 opened issue") + }) +} diff --git a/tests/integration/api_activitypub_person_test.go b/tests/integration/api_activitypub_person_test.go index 04d1fb1648..bd21c13612 100644 --- a/tests/integration/api_activitypub_person_test.go +++ b/tests/integration/api_activitypub_person_test.go @@ -39,9 +39,6 @@ func TestActivityPubPerson(t *testing.T) { localUserName := "user2" localUserURL := fmt.Sprintf("%sapi/v1/activitypub/user-id/%d", localUrl, localUserID) - // distantURL := federatedSrv.URL - // distantUser15URL := fmt.Sprintf("%s/api/v1/activitypub/user-id/15", distantURL) - // Unsigned request t.Run("UnsignedRequest", func(t *testing.T) { req := NewRequest(t, "GET", localUserURL) @@ -111,3 +108,30 @@ func TestActivityPubPersonInbox(t *testing.T) { assert.Equal(t, http.StatusNotAcceptable, resp.StatusCode) }) } + +func TestActivityPubPersonOutbox(t *testing.T) { + defer test.MockVariableValue(&setting.Federation.Enabled, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + mock := test.NewFederationServerMock() + federatedSrv := mock.DistantServer(t) + defer federatedSrv.Close() + + onGiteaRun(t, func(t *testing.T, u *url.URL) { + defer test.MockVariableValue(&setting.AppURL, u.String())() + user2outboxurl := u.JoinPath("/api/v1/activitypub/user-id/2/outbox").String() + + ctx, _ := contexttest.MockAPIContext(t, user2outboxurl) + cf, err := activitypub.NewClientFactoryWithTimeout(60 * time.Second) + require.NoError(t, err) + + c, err := cf.WithKeysDirect(ctx, mock.Persons[0].PrivKey, + mock.Persons[0].KeyID(federatedSrv.URL)) + require.NoError(t, err) + + // request outbox + resp, err := c.Get(user2outboxurl) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) +}