diff --git a/.deadcode-out b/.deadcode-out index 403a6e40d2..e46e09de9a 100644 --- a/.deadcode-out +++ b/.deadcode-out @@ -87,6 +87,9 @@ code.gitea.io/gitea/modules/eventsource Event.String code.gitea.io/gitea/modules/forgefed + NewForgeUndoLike + ForgeUndoLike.UnmarshalJSON + ForgeUndoLike.Validate GetItemByType JSONUnmarshalerFn NotEmpty diff --git a/modules/forgefed/activity.go b/modules/forgefed/activity_like.go similarity index 93% rename from modules/forgefed/activity.go rename to modules/forgefed/activity_like.go index 247abd255a..0f001486b5 100644 --- a/modules/forgefed/activity.go +++ b/modules/forgefed/activity_like.go @@ -21,8 +21,8 @@ type ForgeLike struct { func NewForgeLike(actorIRI, objectIRI string, startTime time.Time) (ForgeLike, error) { result := ForgeLike{} result.Type = ap.LikeType - result.Actor = ap.IRI(actorIRI) // That's us, a User - result.Object = ap.IRI(objectIRI) // That's them, a Repository + result.Actor = ap.IRI(actorIRI) + result.Object = ap.IRI(objectIRI) result.StartTime = startTime if valid, err := validation.IsValid(result); !valid { return ForgeLike{}, err @@ -46,20 +46,23 @@ func (like ForgeLike) Validate() []string { var result []string result = append(result, validation.ValidateNotEmpty(string(like.Type), "type")...) result = append(result, validation.ValidateOneOf(string(like.Type), []any{"Like"}, "type")...) + if like.Actor == nil { result = append(result, "Actor should not be nil.") } else { result = append(result, validation.ValidateNotEmpty(like.Actor.GetID().String(), "actor")...) } - if like.Object == nil { - result = append(result, "Object should not be nil.") - } else { - result = append(result, validation.ValidateNotEmpty(like.Object.GetID().String(), "object")...) - } + result = append(result, validation.ValidateNotEmpty(like.StartTime.String(), "startTime")...) if like.StartTime.IsZero() { result = append(result, "StartTime was invalid.") } + if like.Object == nil { + result = append(result, "Object should not be nil.") + } else { + result = append(result, validation.ValidateNotEmpty(like.Object.GetID().String(), "object")...) + } + return result } diff --git a/modules/forgefed/activity_test.go b/modules/forgefed/activity_like_test.go similarity index 80% rename from modules/forgefed/activity_test.go rename to modules/forgefed/activity_like_test.go index 9a7979c4e6..6b83381cf9 100644 --- a/modules/forgefed/activity_test.go +++ b/modules/forgefed/activity_like_test.go @@ -16,11 +16,11 @@ import ( ) func Test_NewForgeLike(t *testing.T) { + want := []byte(`{"type":"Like","startTime":"2024-03-07T00:00:00Z","actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1","object":"https://codeberg.org/api/v1/activitypub/repository-id/1"}`) + actorIRI := "https://repo.prod.meissa.de/api/v1/activitypub/user-id/1" objectIRI := "https://codeberg.org/api/v1/activitypub/repository-id/1" - want := []byte(`{"type":"Like","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"}`) - - startTime, _ := time.Parse("2006-Jan-02", "2024-Mar-27") + 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) @@ -84,7 +84,6 @@ func Test_LikeUnmarshalJSON(t *testing.T) { wantErr error } - //revive:disable tests := map[string]testPair{ "with ID": { item: []byte(`{"type":"Like","actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1","object":"https://codeberg.org/api/activitypub/repository-id/1"}`), @@ -100,10 +99,9 @@ func Test_LikeUnmarshalJSON(t *testing.T) { "invalid": { item: []byte(`{"type":"Invalid","actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1","object":"https://codeberg.org/api/activitypub/repository-id/1"`), want: &ForgeLike{}, - wantErr: fmt.Errorf("cannot parse JSON:"), + wantErr: fmt.Errorf("cannot parse JSON"), }, } - //revive:enable for name, test := range tests { t.Run(name, func(t *testing.T) { @@ -120,7 +118,9 @@ func Test_LikeUnmarshalJSON(t *testing.T) { } } -func TestActivityValidation(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", @@ -130,35 +130,37 @@ func TestActivityValidation(t *testing.T) { t.Errorf("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 sut.Validate()[0] != "type should not be empty" { - t.Errorf("validation error expected but was: %v\n", sut.Validate()[0]) + if err := validateAndCheckError(sut, "type should not be empty"); err != nil { + t.Error(err) } 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 sut.Validate()[0] != "Value bad-type is not contained in allowed values [Like]" { - t.Errorf("validation error expected but was: %v\n", sut.Validate()[0]) + if err := validateAndCheckError(sut, "Value bad-type is not contained in allowed values [Like]"); err != nil { + t.Error(err) } 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 sut.Validate()[0] != "StartTime was invalid." { - t.Errorf("validation error expected but was: %v\n", sut.Validate()) + "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) } sut.UnmarshalJSON([]byte(`{"type":"Wrong", "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 sut.Validate()[0] != "Value Wrong is not contained in allowed values [Like]" { - t.Errorf("validation error expected but was: %v\n", sut.Validate()) + "object":"https://codeberg.org/api/activitypub/repository-id/1", + "startTime": "2014-12-31T23:00:00-08:00"}`)) + if err := validateAndCheckError(sut, "Value Wrong is not contained in allowed values [Like]"); err != nil { + t.Error(err) } } @@ -166,6 +168,6 @@ func TestActivityValidation_Attack(t *testing.T) { sut := new(ForgeLike) sut.UnmarshalJSON([]byte(`{rubbish}`)) if len(sut.Validate()) != 5 { - t.Errorf("5 validateion errors expected but was: %v\n", len(sut.Validate())) + t.Errorf("5 validation errors expected but was: %v\n", len(sut.Validate())) } } diff --git a/modules/forgefed/activity_undo_like.go b/modules/forgefed/activity_undo_like.go new file mode 100644 index 0000000000..b6b13ba50d --- /dev/null +++ b/modules/forgefed/activity_undo_like.go @@ -0,0 +1,80 @@ +// Copyright 2023, 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package forgefed + +import ( + "time" + + "code.gitea.io/gitea/modules/validation" + + ap "github.com/go-ap/activitypub" +) + +// ForgeLike activity data type +// swagger:model +type ForgeUndoLike struct { + // swagger:ignore + ap.Activity +} + +func NewForgeUndoLike(actorIRI, objectIRI string, startTime time.Time) (ForgeUndoLike, error) { + result := ForgeUndoLike{} + result.Type = ap.UndoType + result.Actor = ap.IRI(actorIRI) + result.StartTime = startTime + + like := ap.Activity{} + like.Type = ap.LikeType + like.Actor = ap.IRI(actorIRI) + like.Object = ap.IRI(objectIRI) + result.Object = &like + + if valid, err := validation.IsValid(result); !valid { + return ForgeUndoLike{}, err + } + return result, nil +} + +func (undo *ForgeUndoLike) UnmarshalJSON(data []byte) error { + return undo.Activity.UnmarshalJSON(data) +} + +func (undo ForgeUndoLike) Validate() []string { + var result []string + result = append(result, validation.ValidateNotEmpty(string(undo.Type), "type")...) + result = append(result, validation.ValidateOneOf(string(undo.Type), []any{"Undo"}, "type")...) + + if undo.Actor == nil { + result = append(result, "Actor should not be nil.") + } else { + result = append(result, validation.ValidateNotEmpty(undo.Actor.GetID().String(), "actor")...) + } + + result = append(result, validation.ValidateNotEmpty(undo.StartTime.String(), "startTime")...) + if undo.StartTime.IsZero() { + result = append(result, "StartTime was invalid.") + } + + if undo.Object == nil { + result = append(result, "object should not be empty.") + } else if activity, ok := undo.Object.(*ap.Activity); !ok { + result = append(result, "object is not of type Activity") + } else { + result = append(result, validation.ValidateNotEmpty(string(activity.Type), "type")...) + result = append(result, validation.ValidateOneOf(string(activity.Type), []any{"Like"}, "type")...) + + if activity.Actor == nil { + result = append(result, "Object.Actor should not be nil.") + } else { + result = append(result, validation.ValidateNotEmpty(activity.Actor.GetID().String(), "actor")...) + } + + if activity.Object == nil { + result = append(result, "Object.Object should not be nil.") + } else { + result = append(result, validation.ValidateNotEmpty(activity.Object.GetID().String(), "object")...) + } + } + return result +} diff --git a/modules/forgefed/activity_undo_like_test.go b/modules/forgefed/activity_undo_like_test.go new file mode 100644 index 0000000000..541e524cb3 --- /dev/null +++ b/modules/forgefed/activity_undo_like_test.go @@ -0,0 +1,246 @@ +// Copyright 2023, 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package forgefed + +import ( + "fmt" + "reflect" + "strings" + "testing" + "time" + + "code.gitea.io/gitea/modules/validation" + + ap "github.com/go-ap/activitypub" +) + +func Test_NewForgeUndoLike(t *testing.T) { + actorIRI := "https://repo.prod.meissa.de/api/v1/activitypub/user-id/1" + objectIRI := "https://codeberg.org/api/v1/activitypub/repository-id/1" + want := []byte(`{"type":"Undo","startTime":"2024-03-27T00:00:00Z",` + + `"actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1",` + + `"object":{` + + `"type":"Like",` + + `"actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1",` + + `"object":"https://codeberg.org/api/v1/activitypub/repository-id/1"}}`) + + startTime, _ := time.Parse("2006-Jan-02", "2024-Mar-27") + sut, err := NewForgeUndoLike(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()) + } + + 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) + } +} + +func Test_UndoLikeMarshalJSON(t *testing.T) { + type testPair struct { + item ForgeUndoLike + want []byte + wantErr error + } + + startTime, _ := time.Parse("2006-Jan-02", "2024-Mar-27") + like, _ := NewForgeLike("https://repo.prod.meissa.de/api/v1/activitypub/user-id/1", "https://codeberg.org/api/v1/activitypub/repository-id/1", startTime) + tests := map[string]testPair{ + "empty": { + item: ForgeUndoLike{}, + want: nil, + }, + "valid": { + item: ForgeUndoLike{ + Activity: ap.Activity{ + StartTime: startTime, + Actor: ap.IRI("https://repo.prod.meissa.de/api/v1/activitypub/user-id/1"), + Type: "Undo", + Object: like, + }, + }, + want: []byte(`{"type":"Undo",` + + `"startTime":"2024-03-27T00:00:00Z",` + + `"actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1",` + + `"object":{` + + `"type":"Like",` + + `"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"}}`), + }, + } + + 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\nwant %q", got, tt.want) + } + }) + } +} + +func Test_UndoLikeUnmarshalJSON(t *testing.T) { + type testPair struct { + item []byte + want *ForgeUndoLike + wantErr error + } + + startTime, _ := time.Parse("2006-Jan-02", "2024-Mar-27") + like, _ := NewForgeLike("https://repo.prod.meissa.de/api/v1/activitypub/user-id/1", "https://codeberg.org/api/v1/activitypub/repository-id/1", startTime) + + tests := map[string]testPair{ + "valid": { + item: []byte(`{"type":"Undo",` + + `"startTime":"2024-03-27T00:00:00Z",` + + `"actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1",` + + `"object":{` + + `"type":"Like",` + + `"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"}}`), + want: &ForgeUndoLike{ + Activity: ap.Activity{ + StartTime: startTime, + Actor: ap.IRI("https://repo.prod.meissa.de/api/v1/activitypub/user-id/1"), + Type: "Undo", + Object: like, + }, + }, + wantErr: nil, + }, + "invalid": { + item: []byte(`invalid JSON`), + want: nil, + wantErr: fmt.Errorf("cannot parse JSON"), + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + got := new(ForgeUndoLike) + err := got.UnmarshalJSON(test.item) + if test.wantErr != nil { + if err == nil { + t.Errorf("UnmarshalJSON() error = nil, wantErr \"%v\"", test.wantErr) + } else if !strings.Contains(err.Error(), test.wantErr.Error()) { + t.Errorf("UnmarshalJSON() error = \"%v\", wantErr \"%v\"", err, test.wantErr) + } + return + } + remarshalledgot, _ := got.MarshalJSON() + remarshalledwant, _ := test.want.MarshalJSON() + if !reflect.DeepEqual(remarshalledgot, remarshalledwant) { + t.Errorf("UnmarshalJSON() got = %#v\nwant %#v", got, test.want) + } + }) + } +} + +func TestActivityValidationUndo(t *testing.T) { + sut := new(ForgeUndoLike) + + _ = sut.UnmarshalJSON([]byte(` + {"type":"Undo", + "startTime":"2024-03-27T00:00:00Z", + "actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1", + "object":{ + "type":"Like", + "actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1", + "object":"https://codeberg.org/api/v1/activitypub/repository-id/1"}}`)) + if res, _ := validation.IsValid(sut); !res { + t.Errorf("sut expected to be valid: %v\n", sut.Validate()) + } + + _ = sut.UnmarshalJSON([]byte(` + {"startTime":"2024-03-27T00:00:00Z", + "actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1", + "object":{ + "type":"Like", + "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 { + t.Error(*err) + } + + _ = sut.UnmarshalJSON([]byte(` + {"type":"Undo", + "startTime":"2024-03-27T00:00:00Z", + "object":{ + "type":"Like", + "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, "Actor should not be nil."); err != nil { + t.Error(*err) + } + + _ = sut.UnmarshalJSON([]byte(` + {"type":"Undo", + "startTime":"2024-03-27T00:00:00Z", + "actor":"string", + "object":{ + "type":"Like", + "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, "Actor should not be nil."); err != nil { + t.Error(*err) + } + + _ = sut.UnmarshalJSON([]byte(` + {"type":"Undo", + "startTime":"2024-03-27T00:00:00Z", + "actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1" + }`)) + if err := validateAndCheckError(sut, "object should not be empty."); err != nil { + t.Error(*err) + } + + _ = sut.UnmarshalJSON([]byte(` + {"type":"Undo", + "startTime":"2024-03-27T00:00:00Z", + "actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1", + "object":{ + "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, "object is not of type Activity"); err != nil { + t.Error(*err) + } + + _ = sut.UnmarshalJSON([]byte(` + {"type":"Undo", + "startTime":"2024-03-27T00:00:00Z", + "actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1", + "object":{ + "type":"Like", + "object":""}}`)) + if err := validateAndCheckError(sut, "Object.Actor should not be nil."); err != nil { + t.Error(*err) + } + + _ = sut.UnmarshalJSON([]byte(` + {"type":"Undo", + "startTime":"2024-03-27T00:00:00Z", + "actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1", + "object":{ + "type":"Like", + "startTime":"2024-03-27T00:00:00Z", + "actor":"https://repo.prod.meissa.de/api/v1/activitypub/user-id/1"}}`)) + if err := validateAndCheckError(sut, "Object.Object should not be nil."); err != nil { + t.Error(*err) + } +} diff --git a/modules/forgefed/activity_validateandcheckerror_test.go b/modules/forgefed/activity_validateandcheckerror_test.go new file mode 100644 index 0000000000..f2f1fbcccb --- /dev/null +++ b/modules/forgefed/activity_validateandcheckerror_test.go @@ -0,0 +1,23 @@ +// Copyright 2023, 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package forgefed + +import ( + "fmt" + + "code.gitea.io/gitea/modules/validation" +) + +func validateAndCheckError(subject validation.Validateable, expectedError string) *string { + errors := subject.Validate() + err := errors[0] + if len(errors) < 1 { + val := "Validation error should have been returned, but was not." + return &val + } else if err != expectedError { + val := fmt.Sprintf("Validation error should be [%v] but was: %v\n", expectedError, err) + return &val + } + return nil +} diff --git a/routers/api/v1/activitypub/repository.go b/routers/api/v1/activitypub/repository.go index bc6e7905a6..14381664d4 100644 --- a/routers/api/v1/activitypub/repository.go +++ b/routers/api/v1/activitypub/repository.go @@ -70,8 +70,8 @@ func RepositoryInbox(ctx *context.APIContext) { repository := ctx.Repo.Repository log.Info("RepositoryInbox: repo: %v", repository) - form := web.GetForm(ctx) + // TODO: Decide between like/undo{like} activity httpStatus, title, err := federation.ProcessLikeActivity(ctx, form, repository.ID) if err != nil { ctx.Error(httpStatus, title, err)