loading up the forgejo repo on tangled to test page performance
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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 <earl-warren@noreply.codeberg.org>
Co-authored-by: Michael Jerger <michael.jerger@meissa-gmbh.de>
Co-committed-by: Michael Jerger <michael.jerger@meissa-gmbh.de>

authored by

Michael Jerger
Michael Jerger
and committed by
Earl Warren
2177d38e 8c3511a8

+1088 -1
+11
.deadcode-out
··· 168 168 package "code.gitea.io/gitea/modules/eventsource" 169 169 func (*Event).String 170 170 171 + package "code.gitea.io/gitea/modules/forgefed" 172 + func NewForgeLike 173 + func GetItemByType 174 + func JSONUnmarshalerFn 175 + func NotEmpty 176 + func ToRepository 177 + func OnRepository 178 + 171 179 package "code.gitea.io/gitea/modules/git" 172 180 func AllowLFSFiltersArgs 173 181 func AddChanges ··· 301 309 302 310 package "code.gitea.io/gitea/modules/util/filebuffer" 303 311 func CreateFromReader 312 + 313 + package "code.gitea.io/gitea/modules/validation" 314 + func ValidateMaxLen 304 315 305 316 package "code.gitea.io/gitea/modules/web" 306 317 func RouteMock
+1 -1
go.mod
··· 94 94 github.com/syndtr/goleveldb v1.0.0 95 95 github.com/ulikunitz/xz v0.5.11 96 96 github.com/urfave/cli/v2 v2.27.2 97 + github.com/valyala/fastjson v1.6.4 97 98 github.com/xanzy/go-gitlab v0.96.0 98 99 github.com/yohcop/openid-go v1.0.1 99 100 github.com/yuin/goldmark v1.7.0 ··· 265 266 github.com/unknwon/com v1.0.1 // indirect 266 267 github.com/valyala/bytebufferpool v1.0.0 // indirect 267 268 github.com/valyala/fasthttp v1.51.0 // indirect 268 - github.com/valyala/fastjson v1.6.4 // indirect 269 269 github.com/x448/float16 v0.8.4 // indirect 270 270 github.com/xanzy/ssh-agent v0.3.3 // indirect 271 271 github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
+65
modules/forgefed/activity.go
··· 1 + // Copyright 2023, 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package forgefed 5 + 6 + import ( 7 + "time" 8 + 9 + "code.gitea.io/gitea/modules/validation" 10 + 11 + ap "github.com/go-ap/activitypub" 12 + ) 13 + 14 + // ForgeLike activity data type 15 + // swagger:model 16 + type ForgeLike struct { 17 + // swagger:ignore 18 + ap.Activity 19 + } 20 + 21 + func NewForgeLike(actorIRI, objectIRI string, startTime time.Time) (ForgeLike, error) { 22 + result := ForgeLike{} 23 + result.Type = ap.LikeType 24 + result.Actor = ap.IRI(actorIRI) // Thats us, a User 25 + result.Object = ap.IRI(objectIRI) // Thats them, a Repository 26 + result.StartTime = startTime 27 + if valid, err := validation.IsValid(result); !valid { 28 + return ForgeLike{}, err 29 + } 30 + return result, nil 31 + } 32 + 33 + func (like ForgeLike) MarshalJSON() ([]byte, error) { 34 + return like.Activity.MarshalJSON() 35 + } 36 + 37 + func (like *ForgeLike) UnmarshalJSON(data []byte) error { 38 + return like.Activity.UnmarshalJSON(data) 39 + } 40 + 41 + func (like ForgeLike) IsNewer(compareTo time.Time) bool { 42 + return like.StartTime.After(compareTo) 43 + } 44 + 45 + func (like ForgeLike) Validate() []string { 46 + var result []string 47 + result = append(result, validation.ValidateNotEmpty(string(like.Type), "type")...) 48 + result = append(result, validation.ValidateOneOf(string(like.Type), []any{"Like"}, "type")...) 49 + if like.Actor == nil { 50 + result = append(result, "Actor should not be nil.") 51 + } else { 52 + result = append(result, validation.ValidateNotEmpty(like.Actor.GetID().String(), "actor")...) 53 + } 54 + if like.Object == nil { 55 + result = append(result, "Object should not be nil.") 56 + } else { 57 + result = append(result, validation.ValidateNotEmpty(like.Object.GetID().String(), "object")...) 58 + } 59 + result = append(result, validation.ValidateNotEmpty(like.StartTime.String(), "startTime")...) 60 + if like.StartTime.IsZero() { 61 + result = append(result, "StartTime was invalid.") 62 + } 63 + 64 + return result 65 + }
+171
modules/forgefed/activity_test.go
··· 1 + // Copyright 2023, 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package forgefed 5 + 6 + import ( 7 + "fmt" 8 + "reflect" 9 + "strings" 10 + "testing" 11 + "time" 12 + 13 + "code.gitea.io/gitea/modules/validation" 14 + 15 + ap "github.com/go-ap/activitypub" 16 + ) 17 + 18 + func Test_NewForgeLike(t *testing.T) { 19 + actorIRI := "https://repo.prod.meissa.de/api/v1/activitypub/user-id/1" 20 + objectIRI := "https://codeberg.org/api/v1/activitypub/repository-id/1" 21 + 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"}`) 22 + 23 + startTime, _ := time.Parse("2006-Jan-02", "2024-Mar-27") 24 + sut, err := NewForgeLike(actorIRI, objectIRI, startTime) 25 + if err != nil { 26 + t.Errorf("unexpected error: %v\n", err) 27 + } 28 + if valid, _ := validation.IsValid(sut); !valid { 29 + t.Errorf("sut expected to be valid: %v\n", sut.Validate()) 30 + } 31 + 32 + got, err := sut.MarshalJSON() 33 + if err != nil { 34 + t.Errorf("MarshalJSON() error = \"%v\"", err) 35 + return 36 + } 37 + if !reflect.DeepEqual(got, want) { 38 + t.Errorf("MarshalJSON() got = %q, want %q", got, want) 39 + } 40 + } 41 + 42 + func Test_LikeMarshalJSON(t *testing.T) { 43 + type testPair struct { 44 + item ForgeLike 45 + want []byte 46 + wantErr error 47 + } 48 + 49 + tests := map[string]testPair{ 50 + "empty": { 51 + item: ForgeLike{}, 52 + want: nil, 53 + }, 54 + "with ID": { 55 + item: ForgeLike{ 56 + Activity: ap.Activity{ 57 + Actor: ap.IRI("https://repo.prod.meissa.de/api/v1/activitypub/user-id/1"), 58 + Type: "Like", 59 + Object: ap.IRI("https://codeberg.org/api/v1/activitypub/repository-id/1"), 60 + }, 61 + }, 62 + 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"}`), 63 + }, 64 + } 65 + 66 + for name, tt := range tests { 67 + t.Run(name, func(t *testing.T) { 68 + got, err := tt.item.MarshalJSON() 69 + if (err != nil || tt.wantErr != nil) && tt.wantErr.Error() != err.Error() { 70 + t.Errorf("MarshalJSON() error = \"%v\", wantErr \"%v\"", err, tt.wantErr) 71 + return 72 + } 73 + if !reflect.DeepEqual(got, tt.want) { 74 + t.Errorf("MarshalJSON() got = %q, want %q", got, tt.want) 75 + } 76 + }) 77 + } 78 + } 79 + 80 + func Test_LikeUnmarshalJSON(t *testing.T) { 81 + type testPair struct { 82 + item []byte 83 + want *ForgeLike 84 + wantErr error 85 + } 86 + 87 + //revive:disable 88 + tests := map[string]testPair{ 89 + "with ID": { 90 + item: []byte(`{"type":"Like","actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1","object":"https://codeberg.org/api/activitypub/repository-id/1"}`), 91 + want: &ForgeLike{ 92 + Activity: ap.Activity{ 93 + Actor: ap.IRI("https://repo.prod.meissa.de/api/activitypub/user-id/1"), 94 + Type: "Like", 95 + Object: ap.IRI("https://codeberg.org/api/activitypub/repository-id/1"), 96 + }, 97 + }, 98 + wantErr: nil, 99 + }, 100 + "invalid": { 101 + item: []byte(`{"type":"Invalid","actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1","object":"https://codeberg.org/api/activitypub/repository-id/1"`), 102 + want: &ForgeLike{}, 103 + wantErr: fmt.Errorf("cannot parse JSON:"), 104 + }, 105 + } 106 + //revive:enable 107 + 108 + for name, test := range tests { 109 + t.Run(name, func(t *testing.T) { 110 + got := new(ForgeLike) 111 + err := got.UnmarshalJSON(test.item) 112 + if (err != nil || test.wantErr != nil) && !strings.Contains(err.Error(), test.wantErr.Error()) { 113 + t.Errorf("UnmarshalJSON() error = \"%v\", wantErr \"%v\"", err, test.wantErr) 114 + return 115 + } 116 + if !reflect.DeepEqual(got, test.want) { 117 + t.Errorf("UnmarshalJSON() got = %q, want %q, err %q", got, test.want, err.Error()) 118 + } 119 + }) 120 + } 121 + } 122 + 123 + func TestActivityValidation(t *testing.T) { 124 + sut := new(ForgeLike) 125 + sut.UnmarshalJSON([]byte(`{"type":"Like", 126 + "actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1", 127 + "object":"https://codeberg.org/api/activitypub/repository-id/1", 128 + "startTime": "2014-12-31T23:00:00-08:00"}`)) 129 + if res, _ := validation.IsValid(sut); !res { 130 + t.Errorf("sut expected to be valid: %v\n", sut.Validate()) 131 + } 132 + 133 + sut.UnmarshalJSON([]byte(`{"actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1", 134 + "object":"https://codeberg.org/api/activitypub/repository-id/1", 135 + "startTime": "2014-12-31T23:00:00-08:00"}`)) 136 + if sut.Validate()[0] != "type should not be empty" { 137 + t.Errorf("validation error expected but was: %v\n", sut.Validate()[0]) 138 + } 139 + 140 + sut.UnmarshalJSON([]byte(`{"type":"bad-type", 141 + "actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1", 142 + "object":"https://codeberg.org/api/activitypub/repository-id/1", 143 + "startTime": "2014-12-31T23:00:00-08:00"}`)) 144 + if sut.Validate()[0] != "Value bad-type is not contained in allowed values [Like]" { 145 + t.Errorf("validation error expected but was: %v\n", sut.Validate()[0]) 146 + } 147 + 148 + sut.UnmarshalJSON([]byte(`{"type":"Like", 149 + "actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1", 150 + "object":"https://codeberg.org/api/activitypub/repository-id/1", 151 + "startTime": "not a date"}`)) 152 + if sut.Validate()[0] != "StartTime was invalid." { 153 + t.Errorf("validation error expected but was: %v\n", sut.Validate()) 154 + } 155 + 156 + sut.UnmarshalJSON([]byte(`{"type":"Wrong", 157 + "actor":"https://repo.prod.meissa.de/api/activitypub/user-id/1", 158 + "object":"https://codeberg.org/api/activitypub/repository-id/1", 159 + "startTime": "2014-12-31T23:00:00-08:00"}`)) 160 + if sut.Validate()[0] != "Value Wrong is not contained in allowed values [Like]" { 161 + t.Errorf("validation error expected but was: %v\n", sut.Validate()) 162 + } 163 + } 164 + 165 + func TestActivityValidation_Attack(t *testing.T) { 166 + sut := new(ForgeLike) 167 + sut.UnmarshalJSON([]byte(`{rubbish}`)) 168 + if len(sut.Validate()) != 5 { 169 + t.Errorf("5 validateion errors expected but was: %v\n", len(sut.Validate())) 170 + } 171 + }
+49
modules/forgefed/forgefed.go
··· 1 + // Copyright 2023 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package forgefed 5 + 6 + import ( 7 + ap "github.com/go-ap/activitypub" 8 + "github.com/valyala/fastjson" 9 + ) 10 + 11 + const ForgeFedNamespaceURI = "https://forgefed.org/ns" 12 + 13 + // GetItemByType instantiates a new ForgeFed object if the type matches 14 + // otherwise it defaults to existing activitypub package typer function. 15 + func GetItemByType(typ ap.ActivityVocabularyType) (ap.Item, error) { 16 + switch typ { 17 + case RepositoryType: 18 + return RepositoryNew(""), nil 19 + } 20 + return ap.GetItemByType(typ) 21 + } 22 + 23 + // JSONUnmarshalerFn is the function that will load the data from a fastjson.Value into an Item 24 + // that the go-ap/activitypub package doesn't know about. 25 + func JSONUnmarshalerFn(typ ap.ActivityVocabularyType, val *fastjson.Value, i ap.Item) error { 26 + switch typ { 27 + case RepositoryType: 28 + return OnRepository(i, func(r *Repository) error { 29 + return JSONLoadRepository(val, r) 30 + }) 31 + } 32 + return nil 33 + } 34 + 35 + // NotEmpty is the function that checks if an object is empty 36 + func NotEmpty(i ap.Item) bool { 37 + if ap.IsNil(i) { 38 + return false 39 + } 40 + switch i.GetType() { 41 + case RepositoryType: 42 + r, err := ToRepository(i) 43 + if err != nil { 44 + return false 45 + } 46 + return ap.NotEmpty(r.Actor) 47 + } 48 + return ap.NotEmpty(i) 49 + }
+111
modules/forgefed/repository.go
··· 1 + // Copyright 2023 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package forgefed 5 + 6 + import ( 7 + "reflect" 8 + "unsafe" 9 + 10 + ap "github.com/go-ap/activitypub" 11 + "github.com/valyala/fastjson" 12 + ) 13 + 14 + const ( 15 + RepositoryType ap.ActivityVocabularyType = "Repository" 16 + ) 17 + 18 + type Repository struct { 19 + ap.Actor 20 + // Team Collection of actors who have management/push access to the repository 21 + Team ap.Item `jsonld:"team,omitempty"` 22 + // Forks OrderedCollection of repositories that are forks of this repository 23 + Forks ap.Item `jsonld:"forks,omitempty"` 24 + // ForkedFrom Identifies the repository which this repository was created as a fork 25 + ForkedFrom ap.Item `jsonld:"forkedFrom,omitempty"` 26 + } 27 + 28 + // RepositoryNew initializes a Repository type actor 29 + func RepositoryNew(id ap.ID) *Repository { 30 + a := ap.ActorNew(id, RepositoryType) 31 + a.Type = RepositoryType 32 + o := Repository{Actor: *a} 33 + return &o 34 + } 35 + 36 + func (r Repository) MarshalJSON() ([]byte, error) { 37 + b, err := r.Actor.MarshalJSON() 38 + if len(b) == 0 || err != nil { 39 + return nil, err 40 + } 41 + 42 + b = b[:len(b)-1] 43 + if r.Team != nil { 44 + ap.JSONWriteItemProp(&b, "team", r.Team) 45 + } 46 + if r.Forks != nil { 47 + ap.JSONWriteItemProp(&b, "forks", r.Forks) 48 + } 49 + if r.ForkedFrom != nil { 50 + ap.JSONWriteItemProp(&b, "forkedFrom", r.ForkedFrom) 51 + } 52 + ap.JSONWrite(&b, '}') 53 + return b, nil 54 + } 55 + 56 + func JSONLoadRepository(val *fastjson.Value, r *Repository) error { 57 + if err := ap.OnActor(&r.Actor, func(a *ap.Actor) error { 58 + return ap.JSONLoadActor(val, a) 59 + }); err != nil { 60 + return err 61 + } 62 + 63 + r.Team = ap.JSONGetItem(val, "team") 64 + r.Forks = ap.JSONGetItem(val, "forks") 65 + r.ForkedFrom = ap.JSONGetItem(val, "forkedFrom") 66 + return nil 67 + } 68 + 69 + func (r *Repository) UnmarshalJSON(data []byte) error { 70 + p := fastjson.Parser{} 71 + val, err := p.ParseBytes(data) 72 + if err != nil { 73 + return err 74 + } 75 + return JSONLoadRepository(val, r) 76 + } 77 + 78 + // ToRepository tries to convert the it Item to a Repository Actor. 79 + func ToRepository(it ap.Item) (*Repository, error) { 80 + switch i := it.(type) { 81 + case *Repository: 82 + return i, nil 83 + case Repository: 84 + return &i, nil 85 + case *ap.Actor: 86 + return (*Repository)(unsafe.Pointer(i)), nil 87 + case ap.Actor: 88 + return (*Repository)(unsafe.Pointer(&i)), nil 89 + default: 90 + // NOTE(marius): this is an ugly way of dealing with the interface conversion error: types from different scopes 91 + typ := reflect.TypeOf(new(Repository)) 92 + if i, ok := reflect.ValueOf(it).Convert(typ).Interface().(*Repository); ok { 93 + return i, nil 94 + } 95 + } 96 + return nil, ap.ErrorInvalidType[ap.Actor](it) 97 + } 98 + 99 + type withRepositoryFn func(*Repository) error 100 + 101 + // OnRepository calls function fn on it Item if it can be asserted to type *Repository 102 + func OnRepository(it ap.Item, fn withRepositoryFn) error { 103 + if it == nil { 104 + return nil 105 + } 106 + ob, err := ToRepository(it) 107 + if err != nil { 108 + return err 109 + } 110 + return fn(ob) 111 + }
+145
modules/forgefed/repository_test.go
··· 1 + // Copyright 2023 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package forgefed 5 + 6 + import ( 7 + "fmt" 8 + "reflect" 9 + "testing" 10 + 11 + "code.gitea.io/gitea/modules/json" 12 + 13 + ap "github.com/go-ap/activitypub" 14 + ) 15 + 16 + func Test_RepositoryMarshalJSON(t *testing.T) { 17 + type testPair struct { 18 + item Repository 19 + want []byte 20 + wantErr error 21 + } 22 + 23 + tests := map[string]testPair{ 24 + "empty": { 25 + item: Repository{}, 26 + want: nil, 27 + }, 28 + "with ID": { 29 + item: Repository{ 30 + Actor: ap.Actor{ 31 + ID: "https://example.com/1", 32 + }, 33 + Team: nil, 34 + }, 35 + want: []byte(`{"id":"https://example.com/1"}`), 36 + }, 37 + "with Team as IRI": { 38 + item: Repository{ 39 + Team: ap.IRI("https://example.com/1"), 40 + Actor: ap.Actor{ 41 + ID: "https://example.com/1", 42 + }, 43 + }, 44 + want: []byte(`{"id":"https://example.com/1","team":"https://example.com/1"}`), 45 + }, 46 + "with Team as IRIs": { 47 + item: Repository{ 48 + Team: ap.ItemCollection{ 49 + ap.IRI("https://example.com/1"), 50 + ap.IRI("https://example.com/2"), 51 + }, 52 + Actor: ap.Actor{ 53 + ID: "https://example.com/1", 54 + }, 55 + }, 56 + want: []byte(`{"id":"https://example.com/1","team":["https://example.com/1","https://example.com/2"]}`), 57 + }, 58 + "with Team as Object": { 59 + item: Repository{ 60 + Team: ap.Object{ID: "https://example.com/1"}, 61 + Actor: ap.Actor{ 62 + ID: "https://example.com/1", 63 + }, 64 + }, 65 + want: []byte(`{"id":"https://example.com/1","team":{"id":"https://example.com/1"}}`), 66 + }, 67 + "with Team as slice of Objects": { 68 + item: Repository{ 69 + Team: ap.ItemCollection{ 70 + ap.Object{ID: "https://example.com/1"}, 71 + ap.Object{ID: "https://example.com/2"}, 72 + }, 73 + Actor: ap.Actor{ 74 + ID: "https://example.com/1", 75 + }, 76 + }, 77 + want: []byte(`{"id":"https://example.com/1","team":[{"id":"https://example.com/1"},{"id":"https://example.com/2"}]}`), 78 + }, 79 + } 80 + 81 + for name, tt := range tests { 82 + t.Run(name, func(t *testing.T) { 83 + got, err := tt.item.MarshalJSON() 84 + if (err != nil || tt.wantErr != nil) && tt.wantErr.Error() != err.Error() { 85 + t.Errorf("MarshalJSON() error = \"%v\", wantErr \"%v\"", err, tt.wantErr) 86 + return 87 + } 88 + if !reflect.DeepEqual(got, tt.want) { 89 + t.Errorf("MarshalJSON() got = %q, want %q", got, tt.want) 90 + } 91 + }) 92 + } 93 + } 94 + 95 + func Test_RepositoryUnmarshalJSON(t *testing.T) { 96 + type testPair struct { 97 + data []byte 98 + want *Repository 99 + wantErr error 100 + } 101 + 102 + tests := map[string]testPair{ 103 + "nil": { 104 + data: nil, 105 + wantErr: fmt.Errorf("cannot parse JSON: %w", fmt.Errorf("cannot parse empty string; unparsed tail: %q", "")), 106 + }, 107 + "empty": { 108 + data: []byte{}, 109 + wantErr: fmt.Errorf("cannot parse JSON: %w", fmt.Errorf("cannot parse empty string; unparsed tail: %q", "")), 110 + }, 111 + "with Type": { 112 + data: []byte(`{"type":"Repository"}`), 113 + want: &Repository{ 114 + Actor: ap.Actor{ 115 + Type: RepositoryType, 116 + }, 117 + }, 118 + }, 119 + "with Type and ID": { 120 + data: []byte(`{"id":"https://example.com/1","type":"Repository"}`), 121 + want: &Repository{ 122 + Actor: ap.Actor{ 123 + ID: "https://example.com/1", 124 + Type: RepositoryType, 125 + }, 126 + }, 127 + }, 128 + } 129 + 130 + for name, tt := range tests { 131 + t.Run(name, func(t *testing.T) { 132 + got := new(Repository) 133 + err := got.UnmarshalJSON(tt.data) 134 + if (err != nil || tt.wantErr != nil) && tt.wantErr.Error() != err.Error() { 135 + t.Errorf("UnmarshalJSON() error = \"%v\", wantErr \"%v\"", err, tt.wantErr) 136 + return 137 + } 138 + if tt.want != nil && !reflect.DeepEqual(got, tt.want) { 139 + jGot, _ := json.Marshal(got) 140 + jWant, _ := json.Marshal(tt.want) 141 + t.Errorf("UnmarshalJSON() got = %s, want %s", jGot, jWant) 142 + } 143 + }) 144 + } 145 + }
+67
modules/validation/validatable.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // Copyright 2023 The Forgejo Authors. All rights reserved. 3 + // SPDX-License-Identifier: MIT 4 + 5 + package validation 6 + 7 + import ( 8 + "fmt" 9 + "strings" 10 + "unicode/utf8" 11 + 12 + "code.gitea.io/gitea/modules/timeutil" 13 + ) 14 + 15 + type Validateable interface { 16 + Validate() []string 17 + } 18 + 19 + func IsValid(v Validateable) (bool, error) { 20 + if err := v.Validate(); len(err) > 0 { 21 + errString := strings.Join(err, "\n") 22 + return false, fmt.Errorf(errString) 23 + } 24 + 25 + return true, nil 26 + } 27 + 28 + func ValidateNotEmpty(value any, name string) []string { 29 + isValid := true 30 + switch v := value.(type) { 31 + case string: 32 + if v == "" { 33 + isValid = false 34 + } 35 + case timeutil.TimeStamp: 36 + if v.IsZero() { 37 + isValid = false 38 + } 39 + case int64: 40 + if v == 0 { 41 + isValid = false 42 + } 43 + default: 44 + isValid = false 45 + } 46 + 47 + if isValid { 48 + return []string{} 49 + } 50 + return []string{fmt.Sprintf("%v should not be empty", name)} 51 + } 52 + 53 + func ValidateMaxLen(value string, maxLen int, name string) []string { 54 + if utf8.RuneCountInString(value) > maxLen { 55 + return []string{fmt.Sprintf("Value %v was longer than %v", name, maxLen)} 56 + } 57 + return []string{} 58 + } 59 + 60 + func ValidateOneOf(value any, allowed []any, name string) []string { 61 + for _, allowedElem := range allowed { 62 + if value == allowedElem { 63 + return []string{} 64 + } 65 + } 66 + return []string{fmt.Sprintf("Value %v is not contained in allowed values %v", value, allowed)} 67 + }
+65
modules/validation/validatable_test.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package validation 5 + 6 + import ( 7 + "testing" 8 + 9 + "code.gitea.io/gitea/modules/timeutil" 10 + ) 11 + 12 + type Sut struct { 13 + valid bool 14 + } 15 + 16 + func (sut Sut) Validate() []string { 17 + if sut.valid { 18 + return []string{} 19 + } 20 + return []string{"invalid"} 21 + } 22 + 23 + func Test_IsValid(t *testing.T) { 24 + sut := Sut{valid: true} 25 + if res, _ := IsValid(sut); !res { 26 + t.Errorf("sut expected to be valid: %v\n", sut.Validate()) 27 + } 28 + sut = Sut{valid: false} 29 + if res, _ := IsValid(sut); res { 30 + t.Errorf("sut expected to be invalid: %v\n", sut.Validate()) 31 + } 32 + } 33 + 34 + func Test_ValidateNotEmpty_ForString(t *testing.T) { 35 + sut := "" 36 + if len(ValidateNotEmpty(sut, "dummyField")) == 0 { 37 + t.Errorf("sut should be invalid") 38 + } 39 + sut = "not empty" 40 + if res := ValidateNotEmpty(sut, "dummyField"); len(res) > 0 { 41 + t.Errorf("sut should be valid but was %q", res) 42 + } 43 + } 44 + 45 + func Test_ValidateNotEmpty_ForTimestamp(t *testing.T) { 46 + sut := timeutil.TimeStamp(0) 47 + if res := ValidateNotEmpty(sut, "dummyField"); len(res) == 0 { 48 + t.Errorf("sut should be invalid") 49 + } 50 + sut = timeutil.TimeStampNow() 51 + if res := ValidateNotEmpty(sut, "dummyField"); len(res) > 0 { 52 + t.Errorf("sut should be valid but was %q", res) 53 + } 54 + } 55 + 56 + func Test_ValidateMaxLen(t *testing.T) { 57 + sut := "0123456789" 58 + if len(ValidateMaxLen(sut, 9, "dummyField")) == 0 { 59 + t.Errorf("sut should be invalid") 60 + } 61 + sut = "0123456789" 62 + if res := ValidateMaxLen(sut, 11, "dummyField"); len(res) > 0 { 63 + t.Errorf("sut should be valid but was %q", res) 64 + } 65 + }
+83
routers/api/v1/activitypub/repository.go
··· 1 + // Copyright 2023, 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package activitypub 5 + 6 + import ( 7 + "fmt" 8 + "net/http" 9 + "strings" 10 + 11 + "code.gitea.io/gitea/modules/forgefed" 12 + "code.gitea.io/gitea/modules/log" 13 + "code.gitea.io/gitea/modules/setting" 14 + "code.gitea.io/gitea/modules/web" 15 + "code.gitea.io/gitea/services/context" 16 + "code.gitea.io/gitea/services/federation" 17 + 18 + ap "github.com/go-ap/activitypub" 19 + ) 20 + 21 + // Repository function returns the Repository actor for a repo 22 + func Repository(ctx *context.APIContext) { 23 + // swagger:operation GET /activitypub/repository-id/{repository-id} activitypub activitypubRepository 24 + // --- 25 + // summary: Returns the Repository actor for a repo 26 + // produces: 27 + // - application/json 28 + // parameters: 29 + // - name: repository-id 30 + // in: path 31 + // description: repository ID of the repo 32 + // type: integer 33 + // required: true 34 + // responses: 35 + // "200": 36 + // "$ref": "#/responses/ActivityPub" 37 + 38 + link := fmt.Sprintf("%s/api/v1/activitypub/repository-id/%d", strings.TrimSuffix(setting.AppURL, "/"), ctx.Repo.Repository.ID) 39 + repo := forgefed.RepositoryNew(ap.IRI(link)) 40 + 41 + repo.Name = ap.NaturalLanguageValuesNew() 42 + err := repo.Name.Set("en", ap.Content(ctx.Repo.Repository.Name)) 43 + if err != nil { 44 + ctx.Error(http.StatusInternalServerError, "Set Name", err) 45 + return 46 + } 47 + response(ctx, repo) 48 + } 49 + 50 + // PersonInbox function handles the incoming data for a repository inbox 51 + func RepositoryInbox(ctx *context.APIContext) { 52 + // swagger:operation POST /activitypub/repository-id/{repository-id}/inbox activitypub activitypubRepositoryInbox 53 + // --- 54 + // summary: Send to the inbox 55 + // produces: 56 + // - application/json 57 + // parameters: 58 + // - name: repository-id 59 + // in: path 60 + // description: repository ID of the repo 61 + // type: integer 62 + // required: true 63 + // - name: body 64 + // in: body 65 + // schema: 66 + // "$ref": "#/definitions/ForgeLike" 67 + // responses: 68 + // "204": 69 + // "$ref": "#/responses/empty" 70 + 71 + repository := ctx.Repo.Repository 72 + log.Info("RepositoryInbox: repo: %v", repository) 73 + 74 + form := web.GetForm(ctx) 75 + httpStatus, title, err := federation.ProcessLikeActivity(ctx, form, repository.ID) 76 + if err != nil { 77 + log.Error("Status: %v", httpStatus) 78 + log.Error("Title: %v", title) 79 + log.Error("Error: %v", err) 80 + ctx.Error(httpStatus, title, err) 81 + } 82 + ctx.Status(http.StatusNoContent) 83 + }
+27
routers/api/v1/activitypub/repository_test.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package activitypub 5 + 6 + import ( 7 + "testing" 8 + 9 + "code.gitea.io/gitea/models/user" 10 + ) 11 + 12 + func Test_UserEmailValidate(t *testing.T) { 13 + sut := "ab@cd.ef" 14 + if err := user.ValidateEmail(sut); err != nil { 15 + t.Errorf("sut should be valid, %v, %v", sut, err) 16 + } 17 + 18 + sut = "83ce13c8-af0b-4112-8327-55a54e54e664@code.cartoon-aa.xyz" 19 + if err := user.ValidateEmail(sut); err != nil { 20 + t.Errorf("sut should be valid, %v, %v", sut, err) 21 + } 22 + 23 + sut = "1" 24 + if err := user.ValidateEmail(sut); err == nil { 25 + t.Errorf("sut should not be valid, %v", sut) 26 + } 27 + }
+35
routers/api/v1/activitypub/response.go
··· 1 + // Copyright 2023 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package activitypub 5 + 6 + import ( 7 + "net/http" 8 + 9 + "code.gitea.io/gitea/modules/activitypub" 10 + "code.gitea.io/gitea/modules/forgefed" 11 + "code.gitea.io/gitea/modules/log" 12 + "code.gitea.io/gitea/services/context" 13 + 14 + ap "github.com/go-ap/activitypub" 15 + "github.com/go-ap/jsonld" 16 + ) 17 + 18 + // Respond with an ActivityStreams object 19 + func response(ctx *context.APIContext, v any) { 20 + binary, err := jsonld.WithContext( 21 + jsonld.IRI(ap.ActivityBaseURI), 22 + jsonld.IRI(ap.SecurityContextURI), 23 + jsonld.IRI(forgefed.ForgeFedNamespaceURI), 24 + ).Marshal(v) 25 + if err != nil { 26 + ctx.ServerError("Marshal", err) 27 + return 28 + } 29 + 30 + ctx.Resp.Header().Add("Content-Type", activitypub.ActivityStreamsContentType) 31 + ctx.Resp.WriteHeader(http.StatusOK) 32 + if _, err = ctx.Resp.Write(binary); err != nil { 33 + log.Error("write to resp err: %v", err) 34 + } 35 + }
+9
routers/api/v1/api.go
··· 1 1 // Copyright 2015 The Gogs Authors. All rights reserved. 2 2 // Copyright 2016 The Gitea Authors. All rights reserved. 3 + // Copyright 2023 The Forgejo Authors. All rights reserved. 3 4 // SPDX-License-Identifier: MIT 4 5 5 6 // Package v1 Gitea API ··· 79 80 repo_model "code.gitea.io/gitea/models/repo" 80 81 "code.gitea.io/gitea/models/unit" 81 82 user_model "code.gitea.io/gitea/models/user" 83 + "code.gitea.io/gitea/modules/forgefed" 82 84 "code.gitea.io/gitea/modules/log" 83 85 "code.gitea.io/gitea/modules/setting" 84 86 api "code.gitea.io/gitea/modules/structs" ··· 802 804 m.Get("", activitypub.Person) 803 805 m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox) 804 806 }, context.UserIDAssignmentAPI()) 807 + m.Group("/repository-id/{repository-id}", func() { 808 + m.Get("", activitypub.Repository) 809 + m.Post("/inbox", 810 + bind(forgefed.ForgeLike{}), 811 + // TODO: activitypub.ReqHTTPSignature(), 812 + activitypub.RepositoryInbox) 813 + }, context.RepositoryIDAssignmentAPI()) 805 814 }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryActivityPub)) 806 815 } 807 816
+5
routers/api/v1/swagger/options.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 1 2 // Copyright 2017 The Gitea Authors. All rights reserved. 2 3 // SPDX-License-Identifier: MIT 3 4 4 5 package swagger 5 6 6 7 import ( 8 + ffed "code.gitea.io/gitea/modules/forgefed" 7 9 api "code.gitea.io/gitea/modules/structs" 8 10 "code.gitea.io/gitea/services/forms" 9 11 ) ··· 14 16 // parameterBodies 15 17 // swagger:response parameterBodies 16 18 type swaggerParameterBodies struct { 19 + // in:body 20 + ForgeLike ffed.ForgeLike 21 + 17 22 // in:body 18 23 AddCollaboratorOption api.AddCollaboratorOption 19 24
+25
services/context/repository.go
··· 1 + // Copyright 2023, 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package context 5 + 6 + import ( 7 + "net/http" 8 + 9 + repo_model "code.gitea.io/gitea/models/repo" 10 + ) 11 + 12 + // RepositoryIDAssignmentAPI returns a middleware to handle context-repo assignment for api routes 13 + func RepositoryIDAssignmentAPI() func(ctx *APIContext) { 14 + return func(ctx *APIContext) { 15 + repositoryID := ctx.ParamsInt64(":repository-id") 16 + 17 + var err error 18 + repository := new(Repository) 19 + repository.Repository, err = repo_model.GetRepositoryByID(ctx, repositoryID) 20 + if err != nil { 21 + ctx.Error(http.StatusNotFound, "GetRepositoryByID", err) 22 + } 23 + ctx.Repo = repository 24 + } 25 + }
+30
services/federation/federation_service.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package federation 5 + 6 + import ( 7 + "context" 8 + "net/http" 9 + 10 + fm "code.gitea.io/gitea/modules/forgefed" 11 + "code.gitea.io/gitea/modules/log" 12 + "code.gitea.io/gitea/modules/validation" 13 + ) 14 + 15 + // ProcessLikeActivity receives a ForgeLike activity and does the following: 16 + // Validation of the activity 17 + // Creation of a (remote) federationHost if not existing 18 + // Creation of a forgefed Person if not existing 19 + // Validation of incoming RepositoryID against Local RepositoryID 20 + // Star the repo if it wasn't already stared 21 + // Do some mitigation against out of order attacks 22 + func ProcessLikeActivity(ctx context.Context, form any, repositoryID int64) (int, string, error) { 23 + activity := form.(*fm.ForgeLike) 24 + if res, err := validation.IsValid(activity); !res { 25 + return http.StatusNotAcceptable, "Invalid activity", err 26 + } 27 + log.Info("Activity validated:%v", activity) 28 + 29 + return 0, "", nil 30 + }
+64
templates/swagger/v1_json.tmpl
··· 23 23 }, 24 24 "basePath": "{{AppSubUrl | JSEscape}}/api/v1", 25 25 "paths": { 26 + "/activitypub/repository-id/{repository-id}": { 27 + "get": { 28 + "produces": [ 29 + "application/json" 30 + ], 31 + "tags": [ 32 + "activitypub" 33 + ], 34 + "summary": "Returns the Repository actor for a repo", 35 + "operationId": "activitypubRepository", 36 + "parameters": [ 37 + { 38 + "type": "integer", 39 + "description": "repository ID of the repo", 40 + "name": "repository-id", 41 + "in": "path", 42 + "required": true 43 + } 44 + ], 45 + "responses": { 46 + "200": { 47 + "$ref": "#/responses/ActivityPub" 48 + } 49 + } 50 + } 51 + }, 52 + "/activitypub/repository-id/{repository-id}/inbox": { 53 + "post": { 54 + "produces": [ 55 + "application/json" 56 + ], 57 + "tags": [ 58 + "activitypub" 59 + ], 60 + "summary": "Send to the inbox", 61 + "operationId": "activitypubRepositoryInbox", 62 + "parameters": [ 63 + { 64 + "type": "integer", 65 + "description": "repository ID of the repo", 66 + "name": "repository-id", 67 + "in": "path", 68 + "required": true 69 + }, 70 + { 71 + "name": "body", 72 + "in": "body", 73 + "schema": { 74 + "$ref": "#/definitions/ForgeLike" 75 + } 76 + } 77 + ], 78 + "responses": { 79 + "204": { 80 + "$ref": "#/responses/empty" 81 + } 82 + } 83 + } 84 + }, 26 85 "/activitypub/user-id/{user-id}": { 27 86 "get": { 28 87 "produces": [ ··· 21372 21431 } 21373 21432 }, 21374 21433 "x-go-package": "code.gitea.io/gitea/modules/structs" 21434 + }, 21435 + "ForgeLike": { 21436 + "description": "ForgeLike activity data type", 21437 + "type": "object", 21438 + "x-go-package": "code.gitea.io/gitea/modules/forgefed" 21375 21439 }, 21376 21440 "GPGKey": { 21377 21441 "description": "GPGKey a user GPG key to sign commit and tag in repository",
+125
tests/integration/api_activitypub_repository_test.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package integration 5 + 6 + import ( 7 + "fmt" 8 + "net/http" 9 + "net/http/httptest" 10 + "net/url" 11 + "testing" 12 + 13 + "code.gitea.io/gitea/models/db" 14 + "code.gitea.io/gitea/models/user" 15 + "code.gitea.io/gitea/modules/activitypub" 16 + forgefed_modules "code.gitea.io/gitea/modules/forgefed" 17 + "code.gitea.io/gitea/modules/setting" 18 + "code.gitea.io/gitea/routers" 19 + 20 + "github.com/stretchr/testify/assert" 21 + ) 22 + 23 + func TestActivityPubRepository(t *testing.T) { 24 + setting.Federation.Enabled = true 25 + testWebRoutes = routers.NormalRoutes() 26 + defer func() { 27 + setting.Federation.Enabled = false 28 + testWebRoutes = routers.NormalRoutes() 29 + }() 30 + 31 + onGiteaRun(t, func(*testing.T, *url.URL) { 32 + repositoryID := 2 33 + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/activitypub/repository-id/%v", repositoryID)) 34 + resp := MakeRequest(t, req, http.StatusOK) 35 + body := resp.Body.Bytes() 36 + assert.Contains(t, string(body), "@context") 37 + 38 + var repository forgefed_modules.Repository 39 + err := repository.UnmarshalJSON(body) 40 + assert.NoError(t, err) 41 + 42 + assert.Regexp(t, fmt.Sprintf("activitypub/repository-id/%v$", repositoryID), repository.GetID().String()) 43 + }) 44 + } 45 + 46 + func TestActivityPubMissingRepository(t *testing.T) { 47 + setting.Federation.Enabled = true 48 + testWebRoutes = routers.NormalRoutes() 49 + defer func() { 50 + setting.Federation.Enabled = false 51 + testWebRoutes = routers.NormalRoutes() 52 + }() 53 + 54 + onGiteaRun(t, func(*testing.T, *url.URL) { 55 + repositoryID := 9999999 56 + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/activitypub/repository-id/%v", repositoryID)) 57 + resp := MakeRequest(t, req, http.StatusNotFound) 58 + assert.Contains(t, resp.Body.String(), "repository does not exist") 59 + }) 60 + } 61 + 62 + func TestActivityPubRepositoryInboxValid(t *testing.T) { 63 + setting.Federation.Enabled = true 64 + testWebRoutes = routers.NormalRoutes() 65 + defer func() { 66 + setting.Federation.Enabled = false 67 + testWebRoutes = routers.NormalRoutes() 68 + }() 69 + 70 + srv := httptest.NewServer(testWebRoutes) 71 + defer srv.Close() 72 + 73 + onGiteaRun(t, func(*testing.T, *url.URL) { 74 + appURL := setting.AppURL 75 + setting.AppURL = srv.URL + "/" 76 + defer func() { 77 + setting.Database.LogSQL = false 78 + setting.AppURL = appURL 79 + }() 80 + actionsUser := user.NewActionsUser() 81 + repositoryID := 2 82 + c, err := activitypub.NewClient(db.DefaultContext, actionsUser, "not used") 83 + assert.NoError(t, err) 84 + repoInboxURL := fmt.Sprintf("%s/api/v1/activitypub/repository-id/%v/inbox", 85 + srv.URL, repositoryID) 86 + 87 + 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"}`, 88 + srv.URL, srv.URL, repositoryID)) 89 + resp, err := c.Post(activity, repoInboxURL) 90 + assert.NoError(t, err) 91 + assert.Equal(t, http.StatusNoContent, resp.StatusCode) 92 + }) 93 + } 94 + 95 + func TestActivityPubRepositoryInboxInvalid(t *testing.T) { 96 + setting.Federation.Enabled = true 97 + testWebRoutes = routers.NormalRoutes() 98 + defer func() { 99 + setting.Federation.Enabled = false 100 + testWebRoutes = routers.NormalRoutes() 101 + }() 102 + 103 + srv := httptest.NewServer(testWebRoutes) 104 + defer srv.Close() 105 + 106 + onGiteaRun(t, func(*testing.T, *url.URL) { 107 + appURL := setting.AppURL 108 + setting.AppURL = srv.URL + "/" 109 + defer func() { 110 + setting.Database.LogSQL = false 111 + setting.AppURL = appURL 112 + }() 113 + actionsUser := user.NewActionsUser() 114 + repositoryID := 2 115 + c, err := activitypub.NewClient(db.DefaultContext, actionsUser, "not used") 116 + assert.NoError(t, err) 117 + repoInboxURL := fmt.Sprintf("%s/api/v1/activitypub/repository-id/%v/inbox", 118 + srv.URL, repositoryID) 119 + 120 + activity := []byte(`{"type":"Wrong"}`) 121 + resp, err := c.Post(activity, repoInboxURL) 122 + assert.NoError(t, err) 123 + assert.Equal(t, http.StatusNotAcceptable, resp.StatusCode) 124 + }) 125 + }