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>
This commit is contained in:
		
					parent
					
						
							
								8c3511a8b3
							
						
					
				
			
			
				commit
				
					
						2177d38e9c
					
				
			
		
					 18 changed files with 1088 additions and 1 deletions
				
			
		| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										2
									
								
								go.mod
									
										
									
									
									
								
							
							
						
						
									
										2
									
								
								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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										65
									
								
								modules/forgefed/activity.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								modules/forgefed/activity.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										171
									
								
								modules/forgefed/activity_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										171
									
								
								modules/forgefed/activity_test.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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()))
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										49
									
								
								modules/forgefed/forgefed.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								modules/forgefed/forgefed.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										111
									
								
								modules/forgefed/repository.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								modules/forgefed/repository.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										145
									
								
								modules/forgefed/repository_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										145
									
								
								modules/forgefed/repository_test.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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)
 | 
			
		||||
			}
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										67
									
								
								modules/validation/validatable.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								modules/validation/validatable.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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)}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										65
									
								
								modules/validation/validatable_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								modules/validation/validatable_test.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										83
									
								
								routers/api/v1/activitypub/repository.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								routers/api/v1/activitypub/repository.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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)
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										27
									
								
								routers/api/v1/activitypub/repository_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								routers/api/v1/activitypub/repository_test.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										35
									
								
								routers/api/v1/activitypub/response.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								routers/api/v1/activitypub/response.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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))
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										25
									
								
								services/context/repository.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								services/context/repository.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										30
									
								
								services/federation/federation_service.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								services/federation/federation_service.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										64
									
								
								templates/swagger/v1_json.tmpl
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										64
									
								
								templates/swagger/v1_json.tmpl
									
										
									
										generated
									
									
									
								
							| 
						 | 
				
			
			@ -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",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										125
									
								
								tests/integration/api_activitypub_repository_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								tests/integration/api_activitypub_repository_test.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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)
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue