diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-10-11 10:27:00 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-10-11 10:27:00 +0000 |
commit | 65aa53fc52ff15efe54df4147564828d535837f8 (patch) | |
tree | 31c51dad04fdcca80e6d3043c8bd49d2f1a51f83 /modules/forgefed | |
parent | Initial commit. (diff) | |
download | forgejo-65aa53fc52ff15efe54df4147564828d535837f8.tar.xz forgejo-65aa53fc52ff15efe54df4147564828d535837f8.zip |
Adding upstream version 8.0.3.HEADupstream/8.0.3upstreamdebian
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'modules/forgefed')
-rw-r--r-- | modules/forgefed/activity.go | 65 | ||||
-rw-r--r-- | modules/forgefed/activity_test.go | 171 | ||||
-rw-r--r-- | modules/forgefed/actor.go | 218 | ||||
-rw-r--r-- | modules/forgefed/actor_test.go | 225 | ||||
-rw-r--r-- | modules/forgefed/forgefed.go | 49 | ||||
-rw-r--r-- | modules/forgefed/nodeinfo.go | 19 | ||||
-rw-r--r-- | modules/forgefed/repository.go | 111 | ||||
-rw-r--r-- | modules/forgefed/repository_test.go | 145 |
8 files changed, 1003 insertions, 0 deletions
diff --git a/modules/forgefed/activity.go b/modules/forgefed/activity.go new file mode 100644 index 00000000..c1ca57c4 --- /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 00000000..9a7979c4 --- /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/actor.go b/modules/forgefed/actor.go new file mode 100644 index 00000000..0ef46185 --- /dev/null +++ b/modules/forgefed/actor.go @@ -0,0 +1,218 @@ +// Copyright 2023, 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package forgefed + +import ( + "fmt" + "net/url" + "strings" + + "code.gitea.io/gitea/modules/validation" + + ap "github.com/go-ap/activitypub" +) + +// ----------------------------- ActorID -------------------------------------------- +type ActorID struct { + ID string + Source string + Schema string + Path string + Host string + Port string + UnvalidatedInput string +} + +// Factory function for ActorID. Created struct is asserted to be valid +func NewActorID(uri string) (ActorID, error) { + result, err := newActorID(uri) + if err != nil { + return ActorID{}, err + } + + if valid, err := validation.IsValid(result); !valid { + return ActorID{}, err + } + + return result, nil +} + +func (id ActorID) AsURI() string { + var result string + if id.Port == "" { + result = fmt.Sprintf("%s://%s/%s/%s", id.Schema, id.Host, id.Path, id.ID) + } else { + result = fmt.Sprintf("%s://%s:%s/%s/%s", id.Schema, id.Host, id.Port, id.Path, id.ID) + } + return result +} + +func (id ActorID) Validate() []string { + var result []string + result = append(result, validation.ValidateNotEmpty(id.ID, "userId")...) + result = append(result, validation.ValidateNotEmpty(id.Schema, "schema")...) + result = append(result, validation.ValidateNotEmpty(id.Path, "path")...) + result = append(result, validation.ValidateNotEmpty(id.Host, "host")...) + result = append(result, validation.ValidateNotEmpty(id.UnvalidatedInput, "unvalidatedInput")...) + + if id.UnvalidatedInput != id.AsURI() { + result = append(result, fmt.Sprintf("not all input was parsed, \nUnvalidated Input:%q \nParsed URI: %q", id.UnvalidatedInput, id.AsURI())) + } + + return result +} + +// ----------------------------- PersonID -------------------------------------------- +type PersonID struct { + ActorID +} + +// Factory function for PersonID. Created struct is asserted to be valid +func NewPersonID(uri, source string) (PersonID, error) { + result, err := newActorID(uri) + if err != nil { + return PersonID{}, err + } + result.Source = source + + // validate Person specific path + personID := PersonID{result} + if valid, err := validation.IsValid(personID); !valid { + return PersonID{}, err + } + + return personID, nil +} + +func (id PersonID) AsWebfinger() string { + result := fmt.Sprintf("@%s@%s", strings.ToLower(id.ID), strings.ToLower(id.Host)) + return result +} + +func (id PersonID) AsLoginName() string { + result := fmt.Sprintf("%s%s", strings.ToLower(id.ID), id.HostSuffix()) + return result +} + +func (id PersonID) HostSuffix() string { + result := fmt.Sprintf("-%s", strings.ToLower(id.Host)) + return result +} + +func (id PersonID) Validate() []string { + result := id.ActorID.Validate() + result = append(result, validation.ValidateNotEmpty(id.Source, "source")...) + result = append(result, validation.ValidateOneOf(id.Source, []any{"forgejo", "gitea"}, "Source")...) + switch id.Source { + case "forgejo", "gitea": + if strings.ToLower(id.Path) != "api/v1/activitypub/user-id" && strings.ToLower(id.Path) != "api/activitypub/user-id" { + result = append(result, fmt.Sprintf("path: %q has to be a person specific api path", id.Path)) + } + } + return result +} + +// ----------------------------- RepositoryID -------------------------------------------- + +type RepositoryID struct { + ActorID +} + +// Factory function for RepositoryID. Created struct is asserted to be valid. +func NewRepositoryID(uri, source string) (RepositoryID, error) { + result, err := newActorID(uri) + if err != nil { + return RepositoryID{}, err + } + result.Source = source + + // validate Person specific + repoID := RepositoryID{result} + if valid, err := validation.IsValid(repoID); !valid { + return RepositoryID{}, err + } + + return repoID, nil +} + +func (id RepositoryID) Validate() []string { + result := id.ActorID.Validate() + result = append(result, validation.ValidateNotEmpty(id.Source, "source")...) + result = append(result, validation.ValidateOneOf(id.Source, []any{"forgejo", "gitea"}, "Source")...) + switch id.Source { + case "forgejo", "gitea": + if strings.ToLower(id.Path) != "api/v1/activitypub/repository-id" && strings.ToLower(id.Path) != "api/activitypub/repository-id" { + result = append(result, fmt.Sprintf("path: %q has to be a repo specific api path", id.Path)) + } + } + return result +} + +func containsEmptyString(ar []string) bool { + for _, elem := range ar { + if elem == "" { + return true + } + } + return false +} + +func removeEmptyStrings(ls []string) []string { + var rs []string + for _, str := range ls { + if str != "" { + rs = append(rs, str) + } + } + return rs +} + +func newActorID(uri string) (ActorID, error) { + validatedURI, err := url.ParseRequestURI(uri) + if err != nil { + return ActorID{}, err + } + pathWithActorID := strings.Split(validatedURI.Path, "/") + if containsEmptyString(pathWithActorID) { + pathWithActorID = removeEmptyStrings(pathWithActorID) + } + length := len(pathWithActorID) + pathWithoutActorID := strings.Join(pathWithActorID[0:length-1], "/") + id := pathWithActorID[length-1] + + result := ActorID{} + result.ID = id + result.Schema = validatedURI.Scheme + result.Host = validatedURI.Hostname() + result.Path = pathWithoutActorID + result.Port = validatedURI.Port() + result.UnvalidatedInput = uri + return result, nil +} + +// ----------------------------- ForgePerson ------------------------------------- + +// ForgePerson activity data type +// swagger:model +type ForgePerson struct { + // swagger:ignore + ap.Actor +} + +func (s ForgePerson) MarshalJSON() ([]byte, error) { + return s.Actor.MarshalJSON() +} + +func (s *ForgePerson) UnmarshalJSON(data []byte) error { + return s.Actor.UnmarshalJSON(data) +} + +func (s ForgePerson) Validate() []string { + var result []string + result = append(result, validation.ValidateNotEmpty(string(s.Type), "Type")...) + result = append(result, validation.ValidateOneOf(string(s.Type), []any{string(ap.PersonType)}, "Type")...) + result = append(result, validation.ValidateNotEmpty(s.PreferredUsername.String(), "PreferredUsername")...) + + return result +} diff --git a/modules/forgefed/actor_test.go b/modules/forgefed/actor_test.go new file mode 100644 index 00000000..a3c01ece --- /dev/null +++ b/modules/forgefed/actor_test.go @@ -0,0 +1,225 @@ +// Copyright 2023, 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package forgefed + +import ( + "reflect" + "strings" + "testing" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/validation" + + ap "github.com/go-ap/activitypub" +) + +func TestNewPersonId(t *testing.T) { + expected := PersonID{} + expected.ID = "1" + expected.Source = "forgejo" + expected.Schema = "https" + expected.Path = "api/v1/activitypub/user-id" + expected.Host = "an.other.host" + expected.Port = "" + expected.UnvalidatedInput = "https://an.other.host/api/v1/activitypub/user-id/1" + sut, _ := NewPersonID("https://an.other.host/api/v1/activitypub/user-id/1", "forgejo") + if sut != expected { + t.Errorf("expected: %v\n but was: %v\n", expected, sut) + } + + expected = PersonID{} + expected.ID = "1" + expected.Source = "forgejo" + expected.Schema = "https" + expected.Path = "api/v1/activitypub/user-id" + expected.Host = "an.other.host" + expected.Port = "443" + expected.UnvalidatedInput = "https://an.other.host:443/api/v1/activitypub/user-id/1" + sut, _ = NewPersonID("https://an.other.host:443/api/v1/activitypub/user-id/1", "forgejo") + if sut != expected { + t.Errorf("expected: %v\n but was: %v\n", expected, sut) + } +} + +func TestNewRepositoryId(t *testing.T) { + setting.AppURL = "http://localhost:3000/" + expected := RepositoryID{} + expected.ID = "1" + expected.Source = "forgejo" + expected.Schema = "http" + expected.Path = "api/activitypub/repository-id" + expected.Host = "localhost" + expected.Port = "3000" + expected.UnvalidatedInput = "http://localhost:3000/api/activitypub/repository-id/1" + sut, _ := NewRepositoryID("http://localhost:3000/api/activitypub/repository-id/1", "forgejo") + if sut != expected { + t.Errorf("expected: %v\n but was: %v\n", expected, sut) + } +} + +func TestActorIdValidation(t *testing.T) { + sut := ActorID{} + sut.Source = "forgejo" + sut.Schema = "https" + sut.Path = "api/v1/activitypub/user-id" + sut.Host = "an.other.host" + sut.Port = "" + sut.UnvalidatedInput = "https://an.other.host/api/v1/activitypub/user-id/" + if sut.Validate()[0] != "userId should not be empty" { + t.Errorf("validation error expected but was: %v\n", sut.Validate()) + } + + sut = ActorID{} + sut.ID = "1" + sut.Source = "forgejo" + sut.Schema = "https" + sut.Path = "api/v1/activitypub/user-id" + sut.Host = "an.other.host" + sut.Port = "" + sut.UnvalidatedInput = "https://an.other.host/api/v1/activitypub/user-id/1?illegal=action" + if sut.Validate()[0] != "not all input was parsed, \nUnvalidated Input:\"https://an.other.host/api/v1/activitypub/user-id/1?illegal=action\" \nParsed URI: \"https://an.other.host/api/v1/activitypub/user-id/1\"" { + t.Errorf("validation error expected but was: %v\n", sut.Validate()[0]) + } +} + +func TestPersonIdValidation(t *testing.T) { + sut := PersonID{} + sut.ID = "1" + sut.Source = "forgejo" + sut.Schema = "https" + sut.Path = "path" + sut.Host = "an.other.host" + sut.Port = "" + sut.UnvalidatedInput = "https://an.other.host/path/1" + + _, err := validation.IsValid(sut) + if validation.IsErrNotValid(err) && strings.Contains(err.Error(), "path: \"path\" has to be a person specific api path\n") { + t.Errorf("validation error expected but was: %v\n", err) + } + + sut = PersonID{} + sut.ID = "1" + sut.Source = "forgejox" + sut.Schema = "https" + sut.Path = "api/v1/activitypub/user-id" + sut.Host = "an.other.host" + sut.Port = "" + sut.UnvalidatedInput = "https://an.other.host/api/v1/activitypub/user-id/1" + if sut.Validate()[0] != "Value forgejox is not contained in allowed values [forgejo gitea]" { + t.Errorf("validation error expected but was: %v\n", sut.Validate()[0]) + } +} + +func TestWebfingerId(t *testing.T) { + sut, _ := NewPersonID("https://codeberg.org/api/v1/activitypub/user-id/12345", "forgejo") + if sut.AsWebfinger() != "@12345@codeberg.org" { + t.Errorf("wrong webfinger: %v", sut.AsWebfinger()) + } + + sut, _ = NewPersonID("https://Codeberg.org/api/v1/activitypub/user-id/12345", "forgejo") + if sut.AsWebfinger() != "@12345@codeberg.org" { + t.Errorf("wrong webfinger: %v", sut.AsWebfinger()) + } +} + +func TestShouldThrowErrorOnInvalidInput(t *testing.T) { + var err any + // TODO: remove after test + //_, err = NewPersonId("", "forgejo") + //if err == nil { + // t.Errorf("empty input should be invalid.") + //} + + _, 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) + } +} + +func Test_PersonMarshalJSON(t *testing.T) { + sut := ForgePerson{} + sut.Type = "Person" + sut.PreferredUsername = ap.NaturalLanguageValuesNew() + sut.PreferredUsername.Set("en", ap.Content("MaxMuster")) + result, _ := sut.MarshalJSON() + if string(result) != "{\"type\":\"Person\",\"preferredUsername\":\"MaxMuster\"}" { + t.Errorf("MarshalJSON() was = %q", result) + } +} + +func Test_PersonUnmarshalJSON(t *testing.T) { + expected := &ForgePerson{ + Actor: ap.Actor{ + Type: "Person", + PreferredUsername: ap.NaturalLanguageValues{ + ap.LangRefValue{Ref: "en", Value: []byte("MaxMuster")}, + }, + }, + } + sut := new(ForgePerson) + err := sut.UnmarshalJSON([]byte(`{"type":"Person","preferredUsername":"MaxMuster"}`)) + if err != nil { + t.Errorf("UnmarshalJSON() unexpected error: %v", err) + } + x, _ := expected.MarshalJSON() + y, _ := sut.MarshalJSON() + if !reflect.DeepEqual(x, y) { + t.Errorf("UnmarshalJSON() expected: %q got: %q", x, y) + } + + expectedStr := strings.ReplaceAll(strings.ReplaceAll(`{ + "id":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/10", + "type":"Person", + "icon":{"type":"Image","mediaType":"image/png","url":"https://federated-repo.prod.meissa.de/avatar/fa7f9c4af2a64f41b1bef292bf872614"}, + "url":"https://federated-repo.prod.meissa.de/stargoose9", + "inbox":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/10/inbox", + "outbox":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/10/outbox", + "preferredUsername":"stargoose9", + "publicKey":{"id":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/10#main-key", + "owner":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/10", + "publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBoj...XAgMBAAE=\n-----END PUBLIC KEY-----\n"}}`, + "\n", ""), + "\t", "") + err = sut.UnmarshalJSON([]byte(expectedStr)) + if err != nil { + t.Errorf("UnmarshalJSON() unexpected error: %v", err) + } + result, _ := sut.MarshalJSON() + if expectedStr != string(result) { + t.Errorf("UnmarshalJSON() expected: %q got: %q", expectedStr, result) + } +} + +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()) + } +} diff --git a/modules/forgefed/forgefed.go b/modules/forgefed/forgefed.go new file mode 100644 index 00000000..234aecf3 --- /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/nodeinfo.go b/modules/forgefed/nodeinfo.go new file mode 100644 index 00000000..b22d2959 --- /dev/null +++ b/modules/forgefed/nodeinfo.go @@ -0,0 +1,19 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package forgefed + +import ( + "fmt" +) + +func (id ActorID) AsWellKnownNodeInfoURI() string { + wellKnownPath := ".well-known/nodeinfo" + var result string + if id.Port == "" { + result = fmt.Sprintf("%s://%s/%s", id.Schema, id.Host, wellKnownPath) + } else { + result = fmt.Sprintf("%s://%s:%s/%s", id.Schema, id.Host, id.Port, wellKnownPath) + } + return result +} diff --git a/modules/forgefed/repository.go b/modules/forgefed/repository.go new file mode 100644 index 00000000..63680ccd --- /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 00000000..13a73c10 --- /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) + } + }) + } +} |