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
|
@ -168,6 +168,14 @@ package "code.gitea.io/gitea/modules/emoji"
|
||||||
package "code.gitea.io/gitea/modules/eventsource"
|
package "code.gitea.io/gitea/modules/eventsource"
|
||||||
func (*Event).String
|
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"
|
package "code.gitea.io/gitea/modules/git"
|
||||||
func AllowLFSFiltersArgs
|
func AllowLFSFiltersArgs
|
||||||
func AddChanges
|
func AddChanges
|
||||||
|
@ -302,6 +310,9 @@ package "code.gitea.io/gitea/modules/translation"
|
||||||
package "code.gitea.io/gitea/modules/util/filebuffer"
|
package "code.gitea.io/gitea/modules/util/filebuffer"
|
||||||
func CreateFromReader
|
func CreateFromReader
|
||||||
|
|
||||||
|
package "code.gitea.io/gitea/modules/validation"
|
||||||
|
func ValidateMaxLen
|
||||||
|
|
||||||
package "code.gitea.io/gitea/modules/web"
|
package "code.gitea.io/gitea/modules/web"
|
||||||
func RouteMock
|
func RouteMock
|
||||||
func RouteMockReset
|
func RouteMockReset
|
||||||
|
|
2
go.mod
2
go.mod
|
@ -94,6 +94,7 @@ require (
|
||||||
github.com/syndtr/goleveldb v1.0.0
|
github.com/syndtr/goleveldb v1.0.0
|
||||||
github.com/ulikunitz/xz v0.5.11
|
github.com/ulikunitz/xz v0.5.11
|
||||||
github.com/urfave/cli/v2 v2.27.2
|
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/xanzy/go-gitlab v0.96.0
|
||||||
github.com/yohcop/openid-go v1.0.1
|
github.com/yohcop/openid-go v1.0.1
|
||||||
github.com/yuin/goldmark v1.7.0
|
github.com/yuin/goldmark v1.7.0
|
||||||
|
@ -265,7 +266,6 @@ require (
|
||||||
github.com/unknwon/com v1.0.1 // indirect
|
github.com/unknwon/com v1.0.1 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasthttp v1.51.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/x448/float16 v0.8.4 // indirect
|
||||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
|
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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()))
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 2015 The Gogs Authors. All rights reserved.
|
||||||
// Copyright 2016 The Gitea Authors. All rights reserved.
|
// Copyright 2016 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2023 The Forgejo Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
// Package v1 Gitea API
|
// Package v1 Gitea API
|
||||||
|
@ -79,6 +80,7 @@ import (
|
||||||
repo_model "code.gitea.io/gitea/models/repo"
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
"code.gitea.io/gitea/models/unit"
|
"code.gitea.io/gitea/models/unit"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
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/log"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
api "code.gitea.io/gitea/modules/structs"
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
|
@ -802,6 +804,13 @@ func Routes() *web.Route {
|
||||||
m.Get("", activitypub.Person)
|
m.Get("", activitypub.Person)
|
||||||
m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox)
|
m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox)
|
||||||
}, context.UserIDAssignmentAPI())
|
}, 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))
|
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryActivityPub))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
|
// Copyright 2024 The Forgejo Authors. All rights reserved.
|
||||||
// Copyright 2017 The Gitea Authors. All rights reserved.
|
// Copyright 2017 The Gitea Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package swagger
|
package swagger
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
ffed "code.gitea.io/gitea/modules/forgefed"
|
||||||
api "code.gitea.io/gitea/modules/structs"
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
"code.gitea.io/gitea/services/forms"
|
"code.gitea.io/gitea/services/forms"
|
||||||
)
|
)
|
||||||
|
@ -14,6 +16,9 @@ import (
|
||||||
// parameterBodies
|
// parameterBodies
|
||||||
// swagger:response parameterBodies
|
// swagger:response parameterBodies
|
||||||
type swaggerParameterBodies struct {
|
type swaggerParameterBodies struct {
|
||||||
|
// in:body
|
||||||
|
ForgeLike ffed.ForgeLike
|
||||||
|
|
||||||
// in:body
|
// in:body
|
||||||
AddCollaboratorOption api.AddCollaboratorOption
|
AddCollaboratorOption api.AddCollaboratorOption
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -23,6 +23,65 @@
|
||||||
},
|
},
|
||||||
"basePath": "{{AppSubUrl | JSEscape}}/api/v1",
|
"basePath": "{{AppSubUrl | JSEscape}}/api/v1",
|
||||||
"paths": {
|
"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}": {
|
"/activitypub/user-id/{user-id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"produces": [
|
"produces": [
|
||||||
|
@ -21373,6 +21432,11 @@
|
||||||
},
|
},
|
||||||
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
"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": {
|
"GPGKey": {
|
||||||
"description": "GPGKey a user GPG key to sign commit and tag in repository",
|
"description": "GPGKey a user GPG key to sign commit and tag in repository",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
|
@ -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…
Reference in New Issue