From 2177d38e9c9067fdcc273d68fb6b3549bca6e89d Mon Sep 17 00:00:00 2001 From: Michael Jerger Date: Tue, 7 May 2024 07:59:49 +0000 Subject: [PATCH] feat(federation): validate like activities (#3494) First step on the way to #1680 The PR will * accept like request on the api * validate activity in a first level You can find * architecture at: https://codeberg.org/meissa/forgejo/src/branch/forgejo-federated-star/docs/unsure-where-to-put/federation-architecture.md Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/3494 Reviewed-by: Earl Warren Co-authored-by: Michael Jerger Co-committed-by: Michael Jerger --- .deadcode-out | 11 ++ go.mod | 2 +- modules/forgefed/activity.go | 65 +++++++ modules/forgefed/activity_test.go | 171 ++++++++++++++++++ modules/forgefed/forgefed.go | 49 +++++ modules/forgefed/repository.go | 111 ++++++++++++ modules/forgefed/repository_test.go | 145 +++++++++++++++ modules/validation/validatable.go | 67 +++++++ modules/validation/validatable_test.go | 65 +++++++ routers/api/v1/activitypub/repository.go | 83 +++++++++ routers/api/v1/activitypub/repository_test.go | 27 +++ routers/api/v1/activitypub/response.go | 35 ++++ routers/api/v1/api.go | 9 + routers/api/v1/swagger/options.go | 5 + services/context/repository.go | 25 +++ services/federation/federation_service.go | 30 +++ templates/swagger/v1_json.tmpl | 64 +++++++ .../api_activitypub_repository_test.go | 125 +++++++++++++ 18 files changed, 1088 insertions(+), 1 deletion(-) create mode 100644 modules/forgefed/activity.go create mode 100644 modules/forgefed/activity_test.go create mode 100644 modules/forgefed/forgefed.go create mode 100644 modules/forgefed/repository.go create mode 100644 modules/forgefed/repository_test.go create mode 100644 modules/validation/validatable.go create mode 100644 modules/validation/validatable_test.go create mode 100644 routers/api/v1/activitypub/repository.go create mode 100644 routers/api/v1/activitypub/repository_test.go create mode 100644 routers/api/v1/activitypub/response.go create mode 100644 services/context/repository.go create mode 100644 services/federation/federation_service.go create mode 100644 tests/integration/api_activitypub_repository_test.go diff --git a/.deadcode-out b/.deadcode-out index 62458dd6b6..f22a9df101 100644 --- a/.deadcode-out +++ b/.deadcode-out @@ -168,6 +168,14 @@ package "code.gitea.io/gitea/modules/emoji" package "code.gitea.io/gitea/modules/eventsource" func (*Event).String +package "code.gitea.io/gitea/modules/forgefed" + func NewForgeLike + func GetItemByType + func JSONUnmarshalerFn + func NotEmpty + func ToRepository + func OnRepository + package "code.gitea.io/gitea/modules/git" func AllowLFSFiltersArgs func AddChanges @@ -302,6 +310,9 @@ package "code.gitea.io/gitea/modules/translation" package "code.gitea.io/gitea/modules/util/filebuffer" func CreateFromReader +package "code.gitea.io/gitea/modules/validation" + func ValidateMaxLen + package "code.gitea.io/gitea/modules/web" func RouteMock func RouteMockReset diff --git a/go.mod b/go.mod index 73e2d2e7ed..460cc38942 100644 --- a/go.mod +++ b/go.mod @@ -94,6 +94,7 @@ require ( github.com/syndtr/goleveldb v1.0.0 github.com/ulikunitz/xz v0.5.11 github.com/urfave/cli/v2 v2.27.2 + github.com/valyala/fastjson v1.6.4 github.com/xanzy/go-gitlab v0.96.0 github.com/yohcop/openid-go v1.0.1 github.com/yuin/goldmark v1.7.0 @@ -265,7 +266,6 @@ require ( github.com/unknwon/com v1.0.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.51.0 // indirect - github.com/valyala/fastjson v1.6.4 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect diff --git a/modules/forgefed/activity.go b/modules/forgefed/activity.go new file mode 100644 index 0000000000..c1ca57c4a8 --- /dev/null +++ b/modules/forgefed/activity.go @@ -0,0 +1,65 @@ +// 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 ForgeLike struct { + // swagger:ignore + ap.Activity +} + +func NewForgeLike(actorIRI, objectIRI string, startTime time.Time) (ForgeLike, error) { + result := ForgeLike{} + result.Type = ap.LikeType + result.Actor = ap.IRI(actorIRI) // Thats us, a User + result.Object = ap.IRI(objectIRI) // Thats them, a Repository + result.StartTime = startTime + if valid, err := validation.IsValid(result); !valid { + return ForgeLike{}, err + } + return result, nil +} + +func (like ForgeLike) MarshalJSON() ([]byte, error) { + return like.Activity.MarshalJSON() +} + +func (like *ForgeLike) UnmarshalJSON(data []byte) error { + return like.Activity.UnmarshalJSON(data) +} + +func (like ForgeLike) IsNewer(compareTo time.Time) bool { + return like.StartTime.After(compareTo) +} + +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.") + } + + return result +} diff --git a/modules/forgefed/activity_test.go b/modules/forgefed/activity_test.go new file mode 100644 index 0000000000..9a7979c4e6 --- /dev/null +++ b/modules/forgefed/activity_test.go @@ -0,0 +1,171 @@ +// 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_NewForgeLike(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":"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") + 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()) + } + + 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_LikeMarshalJSON(t *testing.T) { + type testPair struct { + item ForgeLike + want []byte + wantErr error + } + + tests := map[string]testPair{ + "empty": { + item: ForgeLike{}, + want: nil, + }, + "with ID": { + item: ForgeLike{ + Activity: ap.Activity{ + Actor: ap.IRI("https://repo.prod.meissa.de/api/v1/activitypub/user-id/1"), + Type: "Like", + Object: ap.IRI("https://codeberg.org/api/v1/activitypub/repository-id/1"), + }, + }, + want: []byte(`{"type":"Like","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, want %q", got, tt.want) + } + }) + } +} + +func Test_LikeUnmarshalJSON(t *testing.T) { + type testPair struct { + item []byte + want *ForgeLike + 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"}`), + want: &ForgeLike{ + Activity: ap.Activity{ + Actor: ap.IRI("https://repo.prod.meissa.de/api/activitypub/user-id/1"), + Type: "Like", + Object: ap.IRI("https://codeberg.org/api/activitypub/repository-id/1"), + }, + }, + wantErr: nil, + }, + "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:"), + }, + } + //revive:enable + + for name, test := range tests { + 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 + } + if !reflect.DeepEqual(got, test.want) { + t.Errorf("UnmarshalJSON() got = %q, want %q, err %q", got, test.want, err.Error()) + } + }) + } +} + +func TestActivityValidation(t *testing.T) { + 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()) + } + + 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]) + } + + 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]) + } + + 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()) + } + + 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()) + } +} + +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())) + } +} diff --git a/modules/forgefed/forgefed.go b/modules/forgefed/forgefed.go new file mode 100644 index 0000000000..234aecf3ae --- /dev/null +++ b/modules/forgefed/forgefed.go @@ -0,0 +1,49 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package forgefed + +import ( + ap "github.com/go-ap/activitypub" + "github.com/valyala/fastjson" +) + +const ForgeFedNamespaceURI = "https://forgefed.org/ns" + +// GetItemByType instantiates a new ForgeFed object if the type matches +// otherwise it defaults to existing activitypub package typer function. +func GetItemByType(typ ap.ActivityVocabularyType) (ap.Item, error) { + switch typ { + case RepositoryType: + return RepositoryNew(""), nil + } + return ap.GetItemByType(typ) +} + +// JSONUnmarshalerFn is the function that will load the data from a fastjson.Value into an Item +// that the go-ap/activitypub package doesn't know about. +func JSONUnmarshalerFn(typ ap.ActivityVocabularyType, val *fastjson.Value, i ap.Item) error { + switch typ { + case RepositoryType: + return OnRepository(i, func(r *Repository) error { + return JSONLoadRepository(val, r) + }) + } + return nil +} + +// NotEmpty is the function that checks if an object is empty +func NotEmpty(i ap.Item) bool { + if ap.IsNil(i) { + return false + } + switch i.GetType() { + case RepositoryType: + r, err := ToRepository(i) + if err != nil { + return false + } + return ap.NotEmpty(r.Actor) + } + return ap.NotEmpty(i) +} diff --git a/modules/forgefed/repository.go b/modules/forgefed/repository.go new file mode 100644 index 0000000000..63680ccd35 --- /dev/null +++ b/modules/forgefed/repository.go @@ -0,0 +1,111 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package forgefed + +import ( + "reflect" + "unsafe" + + ap "github.com/go-ap/activitypub" + "github.com/valyala/fastjson" +) + +const ( + RepositoryType ap.ActivityVocabularyType = "Repository" +) + +type Repository struct { + ap.Actor + // Team Collection of actors who have management/push access to the repository + Team ap.Item `jsonld:"team,omitempty"` + // Forks OrderedCollection of repositories that are forks of this repository + Forks ap.Item `jsonld:"forks,omitempty"` + // ForkedFrom Identifies the repository which this repository was created as a fork + ForkedFrom ap.Item `jsonld:"forkedFrom,omitempty"` +} + +// RepositoryNew initializes a Repository type actor +func RepositoryNew(id ap.ID) *Repository { + a := ap.ActorNew(id, RepositoryType) + a.Type = RepositoryType + o := Repository{Actor: *a} + return &o +} + +func (r Repository) MarshalJSON() ([]byte, error) { + b, err := r.Actor.MarshalJSON() + if len(b) == 0 || err != nil { + return nil, err + } + + b = b[:len(b)-1] + if r.Team != nil { + ap.JSONWriteItemProp(&b, "team", r.Team) + } + if r.Forks != nil { + ap.JSONWriteItemProp(&b, "forks", r.Forks) + } + if r.ForkedFrom != nil { + ap.JSONWriteItemProp(&b, "forkedFrom", r.ForkedFrom) + } + ap.JSONWrite(&b, '}') + return b, nil +} + +func JSONLoadRepository(val *fastjson.Value, r *Repository) error { + if err := ap.OnActor(&r.Actor, func(a *ap.Actor) error { + return ap.JSONLoadActor(val, a) + }); err != nil { + return err + } + + r.Team = ap.JSONGetItem(val, "team") + r.Forks = ap.JSONGetItem(val, "forks") + r.ForkedFrom = ap.JSONGetItem(val, "forkedFrom") + return nil +} + +func (r *Repository) UnmarshalJSON(data []byte) error { + p := fastjson.Parser{} + val, err := p.ParseBytes(data) + if err != nil { + return err + } + return JSONLoadRepository(val, r) +} + +// ToRepository tries to convert the it Item to a Repository Actor. +func ToRepository(it ap.Item) (*Repository, error) { + switch i := it.(type) { + case *Repository: + return i, nil + case Repository: + return &i, nil + case *ap.Actor: + return (*Repository)(unsafe.Pointer(i)), nil + case ap.Actor: + return (*Repository)(unsafe.Pointer(&i)), nil + default: + // NOTE(marius): this is an ugly way of dealing with the interface conversion error: types from different scopes + typ := reflect.TypeOf(new(Repository)) + if i, ok := reflect.ValueOf(it).Convert(typ).Interface().(*Repository); ok { + return i, nil + } + } + return nil, ap.ErrorInvalidType[ap.Actor](it) +} + +type withRepositoryFn func(*Repository) error + +// OnRepository calls function fn on it Item if it can be asserted to type *Repository +func OnRepository(it ap.Item, fn withRepositoryFn) error { + if it == nil { + return nil + } + ob, err := ToRepository(it) + if err != nil { + return err + } + return fn(ob) +} diff --git a/modules/forgefed/repository_test.go b/modules/forgefed/repository_test.go new file mode 100644 index 0000000000..13a73c10f4 --- /dev/null +++ b/modules/forgefed/repository_test.go @@ -0,0 +1,145 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package forgefed + +import ( + "fmt" + "reflect" + "testing" + + "code.gitea.io/gitea/modules/json" + + ap "github.com/go-ap/activitypub" +) + +func Test_RepositoryMarshalJSON(t *testing.T) { + type testPair struct { + item Repository + want []byte + wantErr error + } + + tests := map[string]testPair{ + "empty": { + item: Repository{}, + want: nil, + }, + "with ID": { + item: Repository{ + Actor: ap.Actor{ + ID: "https://example.com/1", + }, + Team: nil, + }, + want: []byte(`{"id":"https://example.com/1"}`), + }, + "with Team as IRI": { + item: Repository{ + Team: ap.IRI("https://example.com/1"), + Actor: ap.Actor{ + ID: "https://example.com/1", + }, + }, + want: []byte(`{"id":"https://example.com/1","team":"https://example.com/1"}`), + }, + "with Team as IRIs": { + item: Repository{ + Team: ap.ItemCollection{ + ap.IRI("https://example.com/1"), + ap.IRI("https://example.com/2"), + }, + Actor: ap.Actor{ + ID: "https://example.com/1", + }, + }, + want: []byte(`{"id":"https://example.com/1","team":["https://example.com/1","https://example.com/2"]}`), + }, + "with Team as Object": { + item: Repository{ + Team: ap.Object{ID: "https://example.com/1"}, + Actor: ap.Actor{ + ID: "https://example.com/1", + }, + }, + want: []byte(`{"id":"https://example.com/1","team":{"id":"https://example.com/1"}}`), + }, + "with Team as slice of Objects": { + item: Repository{ + Team: ap.ItemCollection{ + ap.Object{ID: "https://example.com/1"}, + ap.Object{ID: "https://example.com/2"}, + }, + Actor: ap.Actor{ + ID: "https://example.com/1", + }, + }, + want: []byte(`{"id":"https://example.com/1","team":[{"id":"https://example.com/1"},{"id":"https://example.com/2"}]}`), + }, + } + + 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) + } + }) + } +} + +func Test_RepositoryUnmarshalJSON(t *testing.T) { + type testPair struct { + data []byte + want *Repository + wantErr error + } + + tests := map[string]testPair{ + "nil": { + data: nil, + wantErr: fmt.Errorf("cannot parse JSON: %w", fmt.Errorf("cannot parse empty string; unparsed tail: %q", "")), + }, + "empty": { + data: []byte{}, + wantErr: fmt.Errorf("cannot parse JSON: %w", fmt.Errorf("cannot parse empty string; unparsed tail: %q", "")), + }, + "with Type": { + data: []byte(`{"type":"Repository"}`), + want: &Repository{ + Actor: ap.Actor{ + Type: RepositoryType, + }, + }, + }, + "with Type and ID": { + data: []byte(`{"id":"https://example.com/1","type":"Repository"}`), + want: &Repository{ + Actor: ap.Actor{ + ID: "https://example.com/1", + Type: RepositoryType, + }, + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got := new(Repository) + err := got.UnmarshalJSON(tt.data) + if (err != nil || tt.wantErr != nil) && tt.wantErr.Error() != err.Error() { + t.Errorf("UnmarshalJSON() error = \"%v\", wantErr \"%v\"", err, tt.wantErr) + return + } + if tt.want != nil && !reflect.DeepEqual(got, tt.want) { + jGot, _ := json.Marshal(got) + jWant, _ := json.Marshal(tt.want) + t.Errorf("UnmarshalJSON() got = %s, want %s", jGot, jWant) + } + }) + } +} diff --git a/modules/validation/validatable.go b/modules/validation/validatable.go new file mode 100644 index 0000000000..fc38ad2524 --- /dev/null +++ b/modules/validation/validatable.go @@ -0,0 +1,67 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package validation + +import ( + "fmt" + "strings" + "unicode/utf8" + + "code.gitea.io/gitea/modules/timeutil" +) + +type Validateable interface { + Validate() []string +} + +func IsValid(v Validateable) (bool, error) { + if err := v.Validate(); len(err) > 0 { + errString := strings.Join(err, "\n") + return false, fmt.Errorf(errString) + } + + return true, nil +} + +func ValidateNotEmpty(value any, name string) []string { + isValid := true + switch v := value.(type) { + case string: + if v == "" { + isValid = false + } + case timeutil.TimeStamp: + if v.IsZero() { + isValid = false + } + case int64: + if v == 0 { + isValid = false + } + default: + isValid = false + } + + if isValid { + return []string{} + } + return []string{fmt.Sprintf("%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{} +} + +func ValidateOneOf(value any, allowed []any, name string) []string { + for _, allowedElem := range allowed { + if value == allowedElem { + return []string{} + } + } + return []string{fmt.Sprintf("Value %v is not contained in allowed values %v", value, allowed)} +} diff --git a/modules/validation/validatable_test.go b/modules/validation/validatable_test.go new file mode 100644 index 0000000000..fdc21f3223 --- /dev/null +++ b/modules/validation/validatable_test.go @@ -0,0 +1,65 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package validation + +import ( + "testing" + + "code.gitea.io/gitea/modules/timeutil" +) + +type Sut struct { + valid bool +} + +func (sut Sut) Validate() []string { + if sut.valid { + return []string{} + } + return []string{"invalid"} +} + +func Test_IsValid(t *testing.T) { + sut := Sut{valid: true} + if res, _ := IsValid(sut); !res { + t.Errorf("sut expected to be valid: %v\n", sut.Validate()) + } + sut = Sut{valid: false} + if res, _ := IsValid(sut); res { + t.Errorf("sut expected to be invalid: %v\n", sut.Validate()) + } +} + +func Test_ValidateNotEmpty_ForString(t *testing.T) { + sut := "" + if len(ValidateNotEmpty(sut, "dummyField")) == 0 { + t.Errorf("sut should be invalid") + } + sut = "not empty" + if res := ValidateNotEmpty(sut, "dummyField"); len(res) > 0 { + t.Errorf("sut should be valid but was %q", res) + } +} + +func Test_ValidateNotEmpty_ForTimestamp(t *testing.T) { + sut := timeutil.TimeStamp(0) + if res := ValidateNotEmpty(sut, "dummyField"); len(res) == 0 { + t.Errorf("sut should be invalid") + } + sut = timeutil.TimeStampNow() + if res := ValidateNotEmpty(sut, "dummyField"); len(res) > 0 { + t.Errorf("sut should be valid but was %q", res) + } +} + +func Test_ValidateMaxLen(t *testing.T) { + sut := "0123456789" + if len(ValidateMaxLen(sut, 9, "dummyField")) == 0 { + t.Errorf("sut should be invalid") + } + sut = "0123456789" + if res := ValidateMaxLen(sut, 11, "dummyField"); len(res) > 0 { + t.Errorf("sut should be valid but was %q", res) + } +} diff --git a/routers/api/v1/activitypub/repository.go b/routers/api/v1/activitypub/repository.go new file mode 100644 index 0000000000..a9e94f289a --- /dev/null +++ b/routers/api/v1/activitypub/repository.go @@ -0,0 +1,83 @@ +// Copyright 2023, 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package activitypub + +import ( + "fmt" + "net/http" + "strings" + + "code.gitea.io/gitea/modules/forgefed" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/federation" + + ap "github.com/go-ap/activitypub" +) + +// Repository function returns the Repository actor for a repo +func Repository(ctx *context.APIContext) { + // swagger:operation GET /activitypub/repository-id/{repository-id} activitypub activitypubRepository + // --- + // summary: Returns the Repository actor for a repo + // produces: + // - application/json + // parameters: + // - name: repository-id + // in: path + // description: repository ID of the repo + // type: integer + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActivityPub" + + link := fmt.Sprintf("%s/api/v1/activitypub/repository-id/%d", strings.TrimSuffix(setting.AppURL, "/"), ctx.Repo.Repository.ID) + repo := forgefed.RepositoryNew(ap.IRI(link)) + + repo.Name = ap.NaturalLanguageValuesNew() + err := repo.Name.Set("en", ap.Content(ctx.Repo.Repository.Name)) + if err != nil { + ctx.Error(http.StatusInternalServerError, "Set Name", err) + return + } + response(ctx, repo) +} + +// PersonInbox function handles the incoming data for a repository inbox +func RepositoryInbox(ctx *context.APIContext) { + // swagger:operation POST /activitypub/repository-id/{repository-id}/inbox activitypub activitypubRepositoryInbox + // --- + // summary: Send to the inbox + // produces: + // - application/json + // parameters: + // - name: repository-id + // in: path + // description: repository ID of the repo + // type: integer + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/ForgeLike" + // responses: + // "204": + // "$ref": "#/responses/empty" + + repository := ctx.Repo.Repository + log.Info("RepositoryInbox: repo: %v", repository) + + form := web.GetForm(ctx) + httpStatus, title, err := federation.ProcessLikeActivity(ctx, form, repository.ID) + if err != nil { + log.Error("Status: %v", httpStatus) + log.Error("Title: %v", title) + log.Error("Error: %v", err) + ctx.Error(httpStatus, title, err) + } + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/activitypub/repository_test.go b/routers/api/v1/activitypub/repository_test.go new file mode 100644 index 0000000000..acd588d99b --- /dev/null +++ b/routers/api/v1/activitypub/repository_test.go @@ -0,0 +1,27 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package activitypub + +import ( + "testing" + + "code.gitea.io/gitea/models/user" +) + +func Test_UserEmailValidate(t *testing.T) { + sut := "ab@cd.ef" + if err := user.ValidateEmail(sut); err != nil { + t.Errorf("sut should be valid, %v, %v", sut, err) + } + + sut = "83ce13c8-af0b-4112-8327-55a54e54e664@code.cartoon-aa.xyz" + if err := user.ValidateEmail(sut); err != nil { + t.Errorf("sut should be valid, %v, %v", sut, err) + } + + sut = "1" + if err := user.ValidateEmail(sut); err == nil { + t.Errorf("sut should not be valid, %v", sut) + } +} diff --git a/routers/api/v1/activitypub/response.go b/routers/api/v1/activitypub/response.go new file mode 100644 index 0000000000..42ef375f12 --- /dev/null +++ b/routers/api/v1/activitypub/response.go @@ -0,0 +1,35 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package activitypub + +import ( + "net/http" + + "code.gitea.io/gitea/modules/activitypub" + "code.gitea.io/gitea/modules/forgefed" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/services/context" + + ap "github.com/go-ap/activitypub" + "github.com/go-ap/jsonld" +) + +// Respond with an ActivityStreams object +func response(ctx *context.APIContext, v any) { + binary, err := jsonld.WithContext( + jsonld.IRI(ap.ActivityBaseURI), + jsonld.IRI(ap.SecurityContextURI), + jsonld.IRI(forgefed.ForgeFedNamespaceURI), + ).Marshal(v) + if err != nil { + ctx.ServerError("Marshal", 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 e4c848cd2f..07b54406c0 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1,5 +1,6 @@ // Copyright 2015 The Gogs Authors. All rights reserved. // Copyright 2016 The Gitea Authors. All rights reserved. +// Copyright 2023 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT // Package v1 Gitea API @@ -79,6 +80,7 @@ import ( repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/forgefed" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" @@ -802,6 +804,13 @@ func Routes() *web.Route { m.Get("", activitypub.Person) m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox) }, context.UserIDAssignmentAPI()) + m.Group("/repository-id/{repository-id}", func() { + m.Get("", activitypub.Repository) + m.Post("/inbox", + bind(forgefed.ForgeLike{}), + // TODO: activitypub.ReqHTTPSignature(), + activitypub.RepositoryInbox) + }, context.RepositoryIDAssignmentAPI()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryActivityPub)) } diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 6a94c21002..2ebf089304 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -1,9 +1,11 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. // Copyright 2017 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package swagger import ( + ffed "code.gitea.io/gitea/modules/forgefed" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/services/forms" ) @@ -14,6 +16,9 @@ import ( // parameterBodies // swagger:response parameterBodies type swaggerParameterBodies struct { + // in:body + ForgeLike ffed.ForgeLike + // in:body AddCollaboratorOption api.AddCollaboratorOption diff --git a/services/context/repository.go b/services/context/repository.go new file mode 100644 index 0000000000..422ac3f58d --- /dev/null +++ b/services/context/repository.go @@ -0,0 +1,25 @@ +// Copyright 2023, 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package context + +import ( + "net/http" + + repo_model "code.gitea.io/gitea/models/repo" +) + +// RepositoryIDAssignmentAPI returns a middleware to handle context-repo assignment for api routes +func RepositoryIDAssignmentAPI() func(ctx *APIContext) { + return func(ctx *APIContext) { + repositoryID := ctx.ParamsInt64(":repository-id") + + var err error + repository := new(Repository) + repository.Repository, err = repo_model.GetRepositoryByID(ctx, repositoryID) + if err != nil { + ctx.Error(http.StatusNotFound, "GetRepositoryByID", err) + } + ctx.Repo = repository + } +} diff --git a/services/federation/federation_service.go b/services/federation/federation_service.go new file mode 100644 index 0000000000..478b00df96 --- /dev/null +++ b/services/federation/federation_service.go @@ -0,0 +1,30 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package federation + +import ( + "context" + "net/http" + + fm "code.gitea.io/gitea/modules/forgefed" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/validation" +) + +// ProcessLikeActivity receives a ForgeLike activity and does the following: +// Validation of the activity +// Creation of a (remote) federationHost if not existing +// Creation of a forgefed Person if not existing +// Validation of incoming RepositoryID against Local RepositoryID +// Star the repo if it wasn't already stared +// Do some mitigation against out of order attacks +func ProcessLikeActivity(ctx context.Context, form any, repositoryID int64) (int, string, error) { + activity := form.(*fm.ForgeLike) + if res, err := validation.IsValid(activity); !res { + return http.StatusNotAcceptable, "Invalid activity", err + } + log.Info("Activity validated:%v", activity) + + return 0, "", nil +} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index dbf9eb89e2..0137e05434 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -23,6 +23,65 @@ }, "basePath": "{{AppSubUrl | JSEscape}}/api/v1", "paths": { + "/activitypub/repository-id/{repository-id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "activitypub" + ], + "summary": "Returns the Repository actor for a repo", + "operationId": "activitypubRepository", + "parameters": [ + { + "type": "integer", + "description": "repository ID of the repo", + "name": "repository-id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ActivityPub" + } + } + } + }, + "/activitypub/repository-id/{repository-id}/inbox": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "activitypub" + ], + "summary": "Send to the inbox", + "operationId": "activitypubRepositoryInbox", + "parameters": [ + { + "type": "integer", + "description": "repository ID of the repo", + "name": "repository-id", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/ForgeLike" + } + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + } + } + } + }, "/activitypub/user-id/{user-id}": { "get": { "produces": [ @@ -21373,6 +21432,11 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "ForgeLike": { + "description": "ForgeLike activity data type", + "type": "object", + "x-go-package": "code.gitea.io/gitea/modules/forgefed" + }, "GPGKey": { "description": "GPGKey a user GPG key to sign commit and tag in repository", "type": "object", diff --git a/tests/integration/api_activitypub_repository_test.go b/tests/integration/api_activitypub_repository_test.go new file mode 100644 index 0000000000..19bf1cf094 --- /dev/null +++ b/tests/integration/api_activitypub_repository_test.go @@ -0,0 +1,125 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/activitypub" + forgefed_modules "code.gitea.io/gitea/modules/forgefed" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/routers" + + "github.com/stretchr/testify/assert" +) + +func TestActivityPubRepository(t *testing.T) { + setting.Federation.Enabled = true + testWebRoutes = routers.NormalRoutes() + defer func() { + setting.Federation.Enabled = false + testWebRoutes = routers.NormalRoutes() + }() + + onGiteaRun(t, func(*testing.T, *url.URL) { + repositoryID := 2 + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/activitypub/repository-id/%v", repositoryID)) + resp := MakeRequest(t, req, http.StatusOK) + body := resp.Body.Bytes() + assert.Contains(t, string(body), "@context") + + var repository forgefed_modules.Repository + err := repository.UnmarshalJSON(body) + assert.NoError(t, err) + + assert.Regexp(t, fmt.Sprintf("activitypub/repository-id/%v$", repositoryID), repository.GetID().String()) + }) +} + +func TestActivityPubMissingRepository(t *testing.T) { + setting.Federation.Enabled = true + testWebRoutes = routers.NormalRoutes() + defer func() { + setting.Federation.Enabled = false + testWebRoutes = routers.NormalRoutes() + }() + + onGiteaRun(t, func(*testing.T, *url.URL) { + repositoryID := 9999999 + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/activitypub/repository-id/%v", repositoryID)) + resp := MakeRequest(t, req, http.StatusNotFound) + assert.Contains(t, resp.Body.String(), "repository does not exist") + }) +} + +func TestActivityPubRepositoryInboxValid(t *testing.T) { + setting.Federation.Enabled = true + testWebRoutes = routers.NormalRoutes() + defer func() { + setting.Federation.Enabled = false + testWebRoutes = routers.NormalRoutes() + }() + + srv := httptest.NewServer(testWebRoutes) + defer srv.Close() + + onGiteaRun(t, func(*testing.T, *url.URL) { + appURL := setting.AppURL + setting.AppURL = srv.URL + "/" + defer func() { + setting.Database.LogSQL = false + setting.AppURL = appURL + }() + actionsUser := user.NewActionsUser() + repositoryID := 2 + c, err := activitypub.NewClient(db.DefaultContext, actionsUser, "not used") + assert.NoError(t, err) + repoInboxURL := fmt.Sprintf("%s/api/v1/activitypub/repository-id/%v/inbox", + srv.URL, repositoryID) + + activity := []byte(fmt.Sprintf(`{"type":"Like","startTime":"2024-03-27T00:00:00Z","actor":"%s/api/v1/activitypub/user-id/2","object":"%s/api/v1/activitypub/repository-id/%v"}`, + srv.URL, srv.URL, repositoryID)) + resp, err := c.Post(activity, repoInboxURL) + assert.NoError(t, err) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + }) +} + +func TestActivityPubRepositoryInboxInvalid(t *testing.T) { + setting.Federation.Enabled = true + testWebRoutes = routers.NormalRoutes() + defer func() { + setting.Federation.Enabled = false + testWebRoutes = routers.NormalRoutes() + }() + + srv := httptest.NewServer(testWebRoutes) + defer srv.Close() + + onGiteaRun(t, func(*testing.T, *url.URL) { + appURL := setting.AppURL + setting.AppURL = srv.URL + "/" + defer func() { + setting.Database.LogSQL = false + setting.AppURL = appURL + }() + actionsUser := user.NewActionsUser() + repositoryID := 2 + c, err := activitypub.NewClient(db.DefaultContext, actionsUser, "not used") + assert.NoError(t, err) + repoInboxURL := fmt.Sprintf("%s/api/v1/activitypub/repository-id/%v/inbox", + srv.URL, repositoryID) + + activity := []byte(`{"type":"Wrong"}`) + resp, err := c.Post(activity, repoInboxURL) + assert.NoError(t, err) + assert.Equal(t, http.StatusNotAcceptable, resp.StatusCode) + }) +}