next step on the way to federation
This commit is contained in:
		
					parent
					
						
							
								99d1ae52fc
							
						
					
				
			
			
				commit
				
					
						1a76664d56
					
				
			
		
					 11 changed files with 1044 additions and 3 deletions
				
			
		
							
								
								
									
										52
									
								
								models/forgefed/federationhost.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								models/forgefed/federationhost.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,52 @@
 | 
			
		|||
// Copyright 2024 The Forgejo Authors. All rights reserved.
 | 
			
		||||
// SPDX-License-Identifier: MIT
 | 
			
		||||
 | 
			
		||||
package forgefed
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"code.gitea.io/gitea/modules/timeutil"
 | 
			
		||||
	"code.gitea.io/gitea/modules/validation"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// FederationHost data type
 | 
			
		||||
// swagger:model
 | 
			
		||||
type FederationHost struct {
 | 
			
		||||
	ID             int64              `xorm:"pk autoincr"`
 | 
			
		||||
	HostFqdn       string             `xorm:"host_fqdn UNIQUE INDEX VARCHAR(255) NOT NULL"`
 | 
			
		||||
	NodeInfo       NodeInfo           `xorm:"extends NOT NULL"`
 | 
			
		||||
	LatestActivity time.Time          `xorm:"NOT NULL"`
 | 
			
		||||
	Create         timeutil.TimeStamp `xorm:"created"`
 | 
			
		||||
	Updated        timeutil.TimeStamp `xorm:"updated"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Factory function for PersonID. Created struct is asserted to be valid
 | 
			
		||||
func NewFederationHost(nodeInfo NodeInfo, hostFqdn string) (FederationHost, error) {
 | 
			
		||||
	result := FederationHost{
 | 
			
		||||
		HostFqdn: strings.ToLower(hostFqdn),
 | 
			
		||||
		NodeInfo: nodeInfo,
 | 
			
		||||
	}
 | 
			
		||||
	if valid, err := validation.IsValid(result); !valid {
 | 
			
		||||
		return FederationHost{}, err
 | 
			
		||||
	}
 | 
			
		||||
	return result, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Validate collects error strings in a slice and returns this
 | 
			
		||||
func (host FederationHost) Validate() []string {
 | 
			
		||||
	var result []string
 | 
			
		||||
	result = append(result, validation.ValidateNotEmpty(host.HostFqdn, "HostFqdn")...)
 | 
			
		||||
	result = append(result, validation.ValidateMaxLen(host.HostFqdn, 255, "HostFqdn")...)
 | 
			
		||||
	result = append(result, host.NodeInfo.Validate()...)
 | 
			
		||||
	if host.HostFqdn != strings.ToLower(host.HostFqdn) {
 | 
			
		||||
		result = append(result, fmt.Sprintf("HostFqdn has to be lower case but was: %v", host.HostFqdn))
 | 
			
		||||
	}
 | 
			
		||||
	if !host.LatestActivity.IsZero() && host.LatestActivity.After(time.Now().Add(10*time.Minute)) {
 | 
			
		||||
		result = append(result, fmt.Sprintf("Latest Activity may not be far futurer: %v", host.LatestActivity))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return result
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										61
									
								
								models/forgefed/federationhost_repository.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								models/forgefed/federationhost_repository.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,61 @@
 | 
			
		|||
// Copyright 2024 The Forgejo Authors. All rights reserved.
 | 
			
		||||
// SPDX-License-Identifier: MIT
 | 
			
		||||
 | 
			
		||||
package forgefed
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"code.gitea.io/gitea/models/db"
 | 
			
		||||
	"code.gitea.io/gitea/modules/validation"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func init() {
 | 
			
		||||
	db.RegisterModel(new(FederationHost))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func GetFederationHost(ctx context.Context, ID int64) (*FederationHost, error) {
 | 
			
		||||
	host := new(FederationHost)
 | 
			
		||||
	has, err := db.GetEngine(ctx).Where("id=?", ID).Get(host)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	} else if !has {
 | 
			
		||||
		return nil, fmt.Errorf("FederationInfo record %v does not exist", ID)
 | 
			
		||||
	}
 | 
			
		||||
	if res, err := validation.IsValid(host); !res {
 | 
			
		||||
		return nil, fmt.Errorf("FederationInfo is not valid: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	return host, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func FindFederationHostByFqdn(ctx context.Context, fqdn string) (*FederationHost, error) {
 | 
			
		||||
	host := new(FederationHost)
 | 
			
		||||
	has, err := db.GetEngine(ctx).Where("host_fqdn=?", strings.ToLower(fqdn)).Get(host)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	} else if !has {
 | 
			
		||||
		return nil, nil
 | 
			
		||||
	}
 | 
			
		||||
	if res, err := validation.IsValid(host); !res {
 | 
			
		||||
		return nil, fmt.Errorf("FederationInfo is not valid: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	return host, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func CreateFederationHost(ctx context.Context, host *FederationHost) error {
 | 
			
		||||
	if res, err := validation.IsValid(host); !res {
 | 
			
		||||
		return fmt.Errorf("FederationInfo is not valid: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	_, err := db.GetEngine(ctx).Insert(host)
 | 
			
		||||
	return err
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func UpdateFederationHost(ctx context.Context, host *FederationHost) error {
 | 
			
		||||
	if res, err := validation.IsValid(host); !res {
 | 
			
		||||
		return fmt.Errorf("FederationInfo is not valid: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	_, err := db.GetEngine(ctx).ID(host.ID).Update(host)
 | 
			
		||||
	return err
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										55
									
								
								models/forgefed/federationhost_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								models/forgefed/federationhost_test.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,55 @@
 | 
			
		|||
// Copyright 2024 The Forgejo Authors. All rights reserved.
 | 
			
		||||
// SPDX-License-Identifier: MIT
 | 
			
		||||
 | 
			
		||||
package forgefed
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"testing"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"code.gitea.io/gitea/modules/validation"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func Test_FederationHostValidation(t *testing.T) {
 | 
			
		||||
	sut := FederationHost{
 | 
			
		||||
		HostFqdn: "host.do.main",
 | 
			
		||||
		NodeInfo: NodeInfo{
 | 
			
		||||
			SoftwareName: "forgejo",
 | 
			
		||||
		},
 | 
			
		||||
		LatestActivity: time.Now(),
 | 
			
		||||
	}
 | 
			
		||||
	if res, err := validation.IsValid(sut); !res {
 | 
			
		||||
		t.Errorf("sut should be valid but was %q", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	sut = FederationHost{
 | 
			
		||||
		HostFqdn:       "host.do.main",
 | 
			
		||||
		NodeInfo:       NodeInfo{},
 | 
			
		||||
		LatestActivity: time.Now(),
 | 
			
		||||
	}
 | 
			
		||||
	if res, _ := validation.IsValid(sut); res {
 | 
			
		||||
		t.Errorf("sut should be invalid")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	sut = FederationHost{
 | 
			
		||||
		HostFqdn: "host.do.main",
 | 
			
		||||
		NodeInfo: NodeInfo{
 | 
			
		||||
			SoftwareName: "forgejo",
 | 
			
		||||
		},
 | 
			
		||||
		LatestActivity: time.Now().Add(1 * time.Hour),
 | 
			
		||||
	}
 | 
			
		||||
	if res, _ := validation.IsValid(sut); res {
 | 
			
		||||
		t.Errorf("sut should be invalid: Future timestamp")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	sut = FederationHost{
 | 
			
		||||
		HostFqdn: "hOst.do.main",
 | 
			
		||||
		NodeInfo: NodeInfo{
 | 
			
		||||
			SoftwareName: "forgejo",
 | 
			
		||||
		},
 | 
			
		||||
		LatestActivity: time.Now(),
 | 
			
		||||
	}
 | 
			
		||||
	if res, _ := validation.IsValid(sut); res {
 | 
			
		||||
		t.Errorf("sut should be invalid: HostFqdn lower case")
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										123
									
								
								models/forgefed/nodeinfo.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								models/forgefed/nodeinfo.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,123 @@
 | 
			
		|||
// Copyright 2023 The Forgejo Authors. All rights reserved.
 | 
			
		||||
// SPDX-License-Identifier: MIT
 | 
			
		||||
 | 
			
		||||
package forgefed
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"net/url"
 | 
			
		||||
 | 
			
		||||
	"code.gitea.io/gitea/modules/validation"
 | 
			
		||||
 | 
			
		||||
	"github.com/valyala/fastjson"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// ToDo: Search for full text SourceType and Source, also in .md files
 | 
			
		||||
type (
 | 
			
		||||
	SoftwareNameType string
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
const (
 | 
			
		||||
	ForgejoSourceType SoftwareNameType = "forgejo"
 | 
			
		||||
	GiteaSourceType   SoftwareNameType = "gitea"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var KnownSourceTypes = []any{
 | 
			
		||||
	ForgejoSourceType, GiteaSourceType,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ------------------------------------------------ NodeInfoWellKnown ------------------------------------------------
 | 
			
		||||
 | 
			
		||||
// NodeInfo data type
 | 
			
		||||
// swagger:model
 | 
			
		||||
type NodeInfoWellKnown struct {
 | 
			
		||||
	Href string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Factory function for PersonID. Created struct is asserted to be valid
 | 
			
		||||
func NewNodeInfoWellKnown(body []byte) (NodeInfoWellKnown, error) {
 | 
			
		||||
	result, err := NodeInfoWellKnownUnmarshalJSON(body)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return NodeInfoWellKnown{}, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if valid, err := validation.IsValid(result); !valid {
 | 
			
		||||
		return NodeInfoWellKnown{}, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return result, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func NodeInfoWellKnownUnmarshalJSON(data []byte) (NodeInfoWellKnown, error) {
 | 
			
		||||
	p := fastjson.Parser{}
 | 
			
		||||
	val, err := p.ParseBytes(data)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return NodeInfoWellKnown{}, err
 | 
			
		||||
	}
 | 
			
		||||
	href := string(val.GetStringBytes("links", "0", "href"))
 | 
			
		||||
	return NodeInfoWellKnown{Href: href}, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Validate collects error strings in a slice and returns this
 | 
			
		||||
func (node NodeInfoWellKnown) Validate() []string {
 | 
			
		||||
	var result []string
 | 
			
		||||
	result = append(result, validation.ValidateNotEmpty(node.Href, "Href")...)
 | 
			
		||||
 | 
			
		||||
	parsedURL, err := url.Parse(node.Href)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		result = append(result, err.Error())
 | 
			
		||||
		return result
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if parsedURL.Host == "" {
 | 
			
		||||
		result = append(result, "Href has to be absolute")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	result = append(result, validation.ValidateOneOf(parsedURL.Scheme, []any{"http", "https"}, "parsedURL.Scheme")...)
 | 
			
		||||
 | 
			
		||||
	if parsedURL.RawQuery != "" {
 | 
			
		||||
		result = append(result, "Href may not contain query")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return result
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ------------------------------------------------ NodeInfo ------------------------------------------------
 | 
			
		||||
 | 
			
		||||
// NodeInfo data type
 | 
			
		||||
// swagger:model
 | 
			
		||||
type NodeInfo struct {
 | 
			
		||||
	SoftwareName SoftwareNameType
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func NodeInfoUnmarshalJSON(data []byte) (NodeInfo, error) {
 | 
			
		||||
	p := fastjson.Parser{}
 | 
			
		||||
	val, err := p.ParseBytes(data)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return NodeInfo{}, err
 | 
			
		||||
	}
 | 
			
		||||
	source := string(val.GetStringBytes("software", "name"))
 | 
			
		||||
	result := NodeInfo{}
 | 
			
		||||
	result.SoftwareName = SoftwareNameType(source)
 | 
			
		||||
	return result, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func NewNodeInfo(body []byte) (NodeInfo, error) {
 | 
			
		||||
	result, err := NodeInfoUnmarshalJSON(body)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return NodeInfo{}, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if valid, err := validation.IsValid(result); !valid {
 | 
			
		||||
		return NodeInfo{}, err
 | 
			
		||||
	}
 | 
			
		||||
	return result, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Validate collects error strings in a slice and returns this
 | 
			
		||||
func (node NodeInfo) Validate() []string {
 | 
			
		||||
	var result []string
 | 
			
		||||
	result = append(result, validation.ValidateNotEmpty(string(node.SoftwareName), "node.SoftwareName")...)
 | 
			
		||||
	result = append(result, validation.ValidateOneOf(node.SoftwareName, KnownSourceTypes, "node.SoftwareName")...)
 | 
			
		||||
 | 
			
		||||
	return result
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										89
									
								
								models/forgefed/nodeinfo_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								models/forgefed/nodeinfo_test.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,89 @@
 | 
			
		|||
// Copyright 2023 The Forgejo Authors. All rights reserved.
 | 
			
		||||
// SPDX-License-Identifier: MIT
 | 
			
		||||
 | 
			
		||||
package forgefed
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"reflect"
 | 
			
		||||
	"testing"
 | 
			
		||||
 | 
			
		||||
	"code.gitea.io/gitea/modules/validation"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func Test_NodeInfoWellKnownUnmarshalJSON(t *testing.T) {
 | 
			
		||||
	type testPair struct {
 | 
			
		||||
		item    []byte
 | 
			
		||||
		want    NodeInfoWellKnown
 | 
			
		||||
		wantErr error
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	tests := map[string]testPair{
 | 
			
		||||
		"with href": {
 | 
			
		||||
			item: []byte(`{"links":[{"href":"https://federated-repo.prod.meissa.de/api/v1/nodeinfo","rel":"http://nodeinfo.diaspora.software/ns/schema/2.1"}]}`),
 | 
			
		||||
			want: NodeInfoWellKnown{
 | 
			
		||||
				Href: "https://federated-repo.prod.meissa.de/api/v1/nodeinfo",
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		"empty": {
 | 
			
		||||
			item:    []byte(``),
 | 
			
		||||
			wantErr: fmt.Errorf("cannot parse JSON: cannot parse empty string; unparsed tail: \"\""),
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for name, tt := range tests {
 | 
			
		||||
		t.Run(name, func(t *testing.T) {
 | 
			
		||||
			got, err := NodeInfoWellKnownUnmarshalJSON(tt.item)
 | 
			
		||||
			if (err != nil || tt.wantErr != nil) && tt.wantErr.Error() != err.Error() {
 | 
			
		||||
				t.Errorf("UnmarshalJSON() error = \"%v\", wantErr \"%v\"", err, tt.wantErr)
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
			if !reflect.DeepEqual(got, tt.want) {
 | 
			
		||||
				t.Errorf("UnmarshalJSON() got = %q, want %q", got, tt.want)
 | 
			
		||||
			}
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func Test_NodeInfoWellKnownValidate(t *testing.T) {
 | 
			
		||||
	sut := NodeInfoWellKnown{Href: "https://federated-repo.prod.meissa.de/api/v1/nodeinfo"}
 | 
			
		||||
	if b, err := validation.IsValid(sut); !b {
 | 
			
		||||
		t.Errorf("sut should be valid, %v, %v", sut, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	sut = NodeInfoWellKnown{Href: "./federated-repo.prod.meissa.de/api/v1/nodeinfo"}
 | 
			
		||||
	if _, err := validation.IsValid(sut); err.Error() != "Href has to be absolute\nValue  is not contained in allowed values [http https]" {
 | 
			
		||||
		t.Errorf("validation error expected but was: %v\n", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	sut = NodeInfoWellKnown{Href: "https://federated-repo.prod.meissa.de/api/v1/nodeinfo?alert=1"}
 | 
			
		||||
	if _, err := validation.IsValid(sut); err.Error() != "Href may not contain query" {
 | 
			
		||||
		t.Errorf("sut should be valid, %v, %v", sut, err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func Test_NewNodeInfoWellKnown(t *testing.T) {
 | 
			
		||||
	sut, _ := NewNodeInfoWellKnown([]byte(`{"links":[{"href":"https://federated-repo.prod.meissa.de/api/v1/nodeinfo","rel":"http://nodeinfo.diaspora.software/ns/schema/2.1"}]}`))
 | 
			
		||||
	expected := NodeInfoWellKnown{Href: "https://federated-repo.prod.meissa.de/api/v1/nodeinfo"}
 | 
			
		||||
	if sut != expected {
 | 
			
		||||
		t.Errorf("expected was: %v but was: %v", expected, sut)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	_, err := NewNodeInfoWellKnown([]byte(`invalid`))
 | 
			
		||||
	if err == nil {
 | 
			
		||||
		t.Errorf("error was expected here")
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func Test_NewNodeInfo(t *testing.T) {
 | 
			
		||||
	sut, _ := NewNodeInfo([]byte(`{"version":"2.1","software":{"name":"gitea","version":"1.20.0+dev-2539-g5840cc6d3","repository":"https://github.com/go-gitea/gitea.git","homepage":"https://gitea.io/"},"protocols":["activitypub"],"services":{"inbound":[],"outbound":["rss2.0"]},"openRegistrations":true,"usage":{"users":{"total":13,"activeHalfyear":1,"activeMonth":1}},"metadata":{}}`))
 | 
			
		||||
	expected := NodeInfo{SoftwareName: "gitea"}
 | 
			
		||||
	if sut != expected {
 | 
			
		||||
		t.Errorf("expected was: %v but was: %v", expected, sut)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	_, err := NewNodeInfo([]byte(`invalid`))
 | 
			
		||||
	if err == nil {
 | 
			
		||||
		t.Errorf("error was expected here")
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,6 +1,8 @@
 | 
			
		|||
// Copyright 2022 The Gitea Authors. All rights reserved.
 | 
			
		||||
// Copyright 2024 The Forgejo Authors. All rights reserved.
 | 
			
		||||
// SPDX-License-Identifier: MIT
 | 
			
		||||
 | 
			
		||||
// TODO: Think about whether this should be moved to services/activitypub (compare to exosy/services/activitypub/client.go)
 | 
			
		||||
package activitypub
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
| 
						 | 
				
			
			@ -10,11 +12,13 @@ import (
 | 
			
		|||
	"crypto/x509"
 | 
			
		||||
	"encoding/pem"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"io"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	user_model "code.gitea.io/gitea/models/user"
 | 
			
		||||
	"code.gitea.io/gitea/modules/log"
 | 
			
		||||
	"code.gitea.io/gitea/modules/proxy"
 | 
			
		||||
	"code.gitea.io/gitea/modules/setting"
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -84,6 +88,7 @@ func NewClient(ctx context.Context, user *user_model.User, pubID string) (c *Cli
 | 
			
		|||
			Transport: &http.Transport{
 | 
			
		||||
				Proxy: proxy.Proxy(),
 | 
			
		||||
			},
 | 
			
		||||
			Timeout: 5 * time.Second,
 | 
			
		||||
		},
 | 
			
		||||
		algs:        setting.HttpsigAlgs,
 | 
			
		||||
		digestAlg:   httpsig.DigestAlgorithm(setting.Federation.DigestAlgorithm),
 | 
			
		||||
| 
						 | 
				
			
			@ -96,9 +101,9 @@ func NewClient(ctx context.Context, user *user_model.User, pubID string) (c *Cli
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
// NewRequest function
 | 
			
		||||
func (c *Client) NewRequest(b []byte, to string) (req *http.Request, err error) {
 | 
			
		||||
func (c *Client) NewRequest(method string, b []byte, to string) (req *http.Request, err error) {
 | 
			
		||||
	buf := bytes.NewBuffer(b)
 | 
			
		||||
	req, err = http.NewRequest(http.MethodPost, to, buf)
 | 
			
		||||
	req, err = http.NewRequest(method, to, buf)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			@ -116,9 +121,52 @@ func (c *Client) NewRequest(b []byte, to string) (req *http.Request, err error)
 | 
			
		|||
// Post function
 | 
			
		||||
func (c *Client) Post(b []byte, to string) (resp *http.Response, err error) {
 | 
			
		||||
	var req *http.Request
 | 
			
		||||
	if req, err = c.NewRequest(b, to); err != nil {
 | 
			
		||||
	if req, err = c.NewRequest(http.MethodPost, b, to); err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	resp, err = c.client.Do(req)
 | 
			
		||||
	return resp, err
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Create an http GET request with forgejo/gitea specific headers
 | 
			
		||||
func (c *Client) Get(to string) (resp *http.Response, err error) { // ToDo: we might not need the b parameter
 | 
			
		||||
	var req *http.Request
 | 
			
		||||
	emptyBody := []byte{0}
 | 
			
		||||
	if req, err = c.NewRequest(http.MethodGet, emptyBody, to); err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	resp, err = c.client.Do(req)
 | 
			
		||||
	return resp, err
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Create an http GET request with forgejo/gitea specific headers
 | 
			
		||||
func (c *Client) GetBody(uri string) ([]byte, error) {
 | 
			
		||||
	response, err := c.Get(uri)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	log.Debug("Client: got status: %v", response.Status)
 | 
			
		||||
	if response.StatusCode != 200 {
 | 
			
		||||
		err = fmt.Errorf("got non 200 status code for id: %v", uri)
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	defer response.Body.Close()
 | 
			
		||||
	body, err := io.ReadAll(response.Body)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	log.Debug("Client: got body: %v", charLimiter(string(body), 120))
 | 
			
		||||
	return body, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Limit number of characters in a string (useful to prevent log injection attacks and overly long log outputs)
 | 
			
		||||
// Thanks to https://www.socketloop.com/tutorials/golang-characters-limiter-example
 | 
			
		||||
func charLimiter(s string, limit int) string {
 | 
			
		||||
	reader := strings.NewReader(s)
 | 
			
		||||
	buff := make([]byte, limit)
 | 
			
		||||
	n, _ := io.ReadAtLeast(reader, buff, limit)
 | 
			
		||||
	if n != 0 {
 | 
			
		||||
		return fmt.Sprint(string(buff), "...")
 | 
			
		||||
	}
 | 
			
		||||
	return s
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,5 @@
 | 
			
		|||
// Copyright 2022 The Gitea Authors. All rights reserved.
 | 
			
		||||
// Copyright 2023 The Forgejo Authors. All rights reserved.
 | 
			
		||||
// SPDX-License-Identifier: MIT
 | 
			
		||||
 | 
			
		||||
package activitypub
 | 
			
		||||
| 
						 | 
				
			
			@ -14,11 +15,87 @@ import (
 | 
			
		|||
	"code.gitea.io/gitea/models/db"
 | 
			
		||||
	"code.gitea.io/gitea/models/unittest"
 | 
			
		||||
	user_model "code.gitea.io/gitea/models/user"
 | 
			
		||||
	"code.gitea.io/gitea/modules/log"
 | 
			
		||||
	"code.gitea.io/gitea/modules/setting"
 | 
			
		||||
 | 
			
		||||
	"github.com/stretchr/testify/assert"
 | 
			
		||||
 | 
			
		||||
	_ "github.com/mattn/go-sqlite3"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
/* ToDo: Set Up tests for http get requests
 | 
			
		||||
 | 
			
		||||
Set up an expected response for GET on api with user-id = 1:
 | 
			
		||||
{
 | 
			
		||||
  "@context": [
 | 
			
		||||
    "https://www.w3.org/ns/activitystreams",
 | 
			
		||||
    "https://w3id.org/security/v1"
 | 
			
		||||
  ],
 | 
			
		||||
  "id": "http://localhost:3000/api/v1/activitypub/user-id/1",
 | 
			
		||||
  "type": "Person",
 | 
			
		||||
  "icon": {
 | 
			
		||||
    "type": "Image",
 | 
			
		||||
    "mediaType": "image/png",
 | 
			
		||||
    "url": "http://localhost:3000/avatar/3120fd0edc57d5d41230013ad88232e2"
 | 
			
		||||
  },
 | 
			
		||||
  "url": "http://localhost:3000/me",
 | 
			
		||||
  "inbox": "http://localhost:3000/api/v1/activitypub/user-id/1/inbox",
 | 
			
		||||
  "outbox": "http://localhost:3000/api/v1/activitypub/user-id/1/outbox",
 | 
			
		||||
  "preferredUsername": "me",
 | 
			
		||||
  "publicKey": {
 | 
			
		||||
    "id": "http://localhost:3000/api/v1/activitypub/user-id/1#main-key",
 | 
			
		||||
    "owner": "http://localhost:3000/api/v1/activitypub/user-id/1",
 | 
			
		||||
    "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAo1VDZGWQBDTWKhpWiPQp\n7nD94UsKkcoFwDQVuxE3bMquKEHBomB4cwUnVou922YkL3AmSOr1sX2yJQGqnCLm\nOeKS74/mCIAoYlu0d75bqY4A7kE2VrQmQLZBbmpCTfrPqDaE6Mfm/kXaX7+hsrZS\n4bVvzZCYq8sjtRxdPk+9ku2QhvznwTRlWLvwHmFSGtlQYPRu+f/XqoVM/DVRA/Is\nwDk9yiNIecV+Isus0CBq1jGQkfuVNu1GK2IvcSg9MoDm3VH/tCayAP+xWm0g7sC8\nKay6Y/khvTvE7bWEKGQsJGvi3+4wITLVLVt+GoVOuCzdbhTV2CHBzn7h30AoZD0N\nY6eyb+Q142JykoHadcRwh1a36wgoG7E496wPvV3ST8xdiClca8cDNhOzCj8woY+t\nTFCMl32U3AJ4e/cAsxKRocYLZqc95dDqdNQiIyiRMMkf5NaA/QvelY4PmFuHC0WR\nVuJ4A3mcti2QLS9j0fSwSJdlfolgW6xaPgjdvuSQsgX1AgMBAAE=\n-----END PUBLIC KEY-----\n"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
Set up a user called "me" for all tests
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
func TestNewClientReturnsClient(t *testing.T) {
 | 
			
		||||
	assert.NoError(t, unittest.PrepareTestDatabase())
 | 
			
		||||
	user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
 | 
			
		||||
	pubID := "myGpgId"
 | 
			
		||||
	c, err := NewClient(db.DefaultContext, user, pubID)
 | 
			
		||||
 | 
			
		||||
	log.Debug("Client: %v\nError: %v", c, err)
 | 
			
		||||
	assert.NoError(t, err)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* TODO: bring this test to work or delete
 | 
			
		||||
func TestActivityPubSignedGet(t *testing.T) {
 | 
			
		||||
	assert.NoError(t, unittest.PrepareTestDatabase())
 | 
			
		||||
	user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1, Name: "me"})
 | 
			
		||||
	pubID := "myGpgId"
 | 
			
		||||
	c, err := NewClient(db.DefaultContext, user, pubID)
 | 
			
		||||
	assert.NoError(t, err)
 | 
			
		||||
 | 
			
		||||
	expected := "TestActivityPubSignedGet"
 | 
			
		||||
 | 
			
		||||
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		assert.Regexp(t, regexp.MustCompile("^"+setting.Federation.DigestAlgorithm), r.Header.Get("Digest"))
 | 
			
		||||
		assert.Contains(t, r.Header.Get("Signature"), pubID)
 | 
			
		||||
		assert.Equal(t, r.Header.Get("Content-Type"), ActivityStreamsContentType)
 | 
			
		||||
		body, err := io.ReadAll(r.Body)
 | 
			
		||||
		assert.NoError(t, err)
 | 
			
		||||
		assert.Equal(t, expected, string(body))
 | 
			
		||||
		fmt.Fprint(w, expected)
 | 
			
		||||
	}))
 | 
			
		||||
	defer srv.Close()
 | 
			
		||||
 | 
			
		||||
	r, err := c.Get(srv.URL)
 | 
			
		||||
	assert.NoError(t, err)
 | 
			
		||||
	defer r.Body.Close()
 | 
			
		||||
	body, err := io.ReadAll(r.Body)
 | 
			
		||||
	assert.NoError(t, err)
 | 
			
		||||
	assert.Equal(t, expected, string(body))
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
func TestActivityPubSignedPost(t *testing.T) {
 | 
			
		||||
	assert.NoError(t, unittest.PrepareTestDatabase())
 | 
			
		||||
	user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										226
									
								
								modules/forgefed/actor.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										226
									
								
								modules/forgefed/actor.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,226 @@
 | 
			
		|||
// Copyright 2023, 2024 The Forgejo Authors. All rights reserved.
 | 
			
		||||
// SPDX-License-Identifier: MIT
 | 
			
		||||
 | 
			
		||||
package forgefed
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"net/url"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"code.gitea.io/gitea/modules/setting"
 | 
			
		||||
	"code.gitea.io/gitea/modules/validation"
 | 
			
		||||
 | 
			
		||||
	ap "github.com/go-ap/activitypub"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// ----------------------------- ActorID --------------------------------------------
 | 
			
		||||
type ActorID struct {
 | 
			
		||||
	ID               string
 | 
			
		||||
	Source           string
 | 
			
		||||
	Schema           string
 | 
			
		||||
	Path             string
 | 
			
		||||
	Host             string
 | 
			
		||||
	Port             string
 | 
			
		||||
	UnvalidatedInput string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Factory function for ActorID. Created struct is asserted to be valid
 | 
			
		||||
func NewActorID(uri string) (ActorID, error) {
 | 
			
		||||
	result, err := newActorID(uri)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return ActorID{}, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if valid, outcome := validation.IsValid(result); !valid {
 | 
			
		||||
		return ActorID{}, outcome
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return result, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (id ActorID) AsURI() string {
 | 
			
		||||
	var result string
 | 
			
		||||
	if id.Port == "" {
 | 
			
		||||
		result = fmt.Sprintf("%s://%s/%s/%s", id.Schema, id.Host, id.Path, id.ID)
 | 
			
		||||
	} else {
 | 
			
		||||
		result = fmt.Sprintf("%s://%s:%s/%s/%s", id.Schema, id.Host, id.Port, id.Path, id.ID)
 | 
			
		||||
	}
 | 
			
		||||
	return result
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (id ActorID) Validate() []string {
 | 
			
		||||
	var result []string
 | 
			
		||||
	result = append(result, validation.ValidateNotEmpty(id.ID, "userId")...)
 | 
			
		||||
	result = append(result, validation.ValidateNotEmpty(id.Schema, "schema")...)
 | 
			
		||||
	result = append(result, validation.ValidateNotEmpty(id.Path, "path")...)
 | 
			
		||||
	result = append(result, validation.ValidateNotEmpty(id.Host, "host")...)
 | 
			
		||||
	result = append(result, validation.ValidateNotEmpty(id.UnvalidatedInput, "unvalidatedInput")...)
 | 
			
		||||
 | 
			
		||||
	if id.UnvalidatedInput != id.AsURI() {
 | 
			
		||||
		result = append(result, fmt.Sprintf("not all input was parsed, \nUnvalidated Input:%q \nParsed URI: %q", id.UnvalidatedInput, id.AsURI()))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return result
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ----------------------------- PersonID --------------------------------------------
 | 
			
		||||
type PersonID struct {
 | 
			
		||||
	ActorID
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Factory function for PersonID. Created struct is asserted to be valid
 | 
			
		||||
func NewPersonID(uri, source string) (PersonID, error) {
 | 
			
		||||
	// TODO: remove after test
 | 
			
		||||
	//if !validation.IsValidExternalURL(uri) {
 | 
			
		||||
	//	return PersonId{}, fmt.Errorf("uri %s is not a valid external url", uri)
 | 
			
		||||
	//}
 | 
			
		||||
	result, err := newActorID(uri)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return PersonID{}, err
 | 
			
		||||
	}
 | 
			
		||||
	result.Source = source
 | 
			
		||||
 | 
			
		||||
	// validate Person specific path
 | 
			
		||||
	personID := PersonID{result}
 | 
			
		||||
	if valid, outcome := validation.IsValid(personID); !valid {
 | 
			
		||||
		return PersonID{}, outcome
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return personID, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (id PersonID) AsWebfinger() string {
 | 
			
		||||
	result := fmt.Sprintf("@%s@%s", strings.ToLower(id.ID), strings.ToLower(id.Host))
 | 
			
		||||
	return result
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (id PersonID) AsLoginName() string {
 | 
			
		||||
	result := fmt.Sprintf("%s%s", strings.ToLower(id.ID), id.HostSuffix())
 | 
			
		||||
	return result
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (id PersonID) HostSuffix() string {
 | 
			
		||||
	result := fmt.Sprintf("-%s", strings.ToLower(id.Host))
 | 
			
		||||
	return result
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (id PersonID) Validate() []string {
 | 
			
		||||
	result := id.ActorID.Validate()
 | 
			
		||||
	result = append(result, validation.ValidateNotEmpty(id.Source, "source")...)
 | 
			
		||||
	result = append(result, validation.ValidateOneOf(id.Source, []any{"forgejo", "gitea"}, "Source")...)
 | 
			
		||||
	switch id.Source {
 | 
			
		||||
	case "forgejo", "gitea":
 | 
			
		||||
		if strings.ToLower(id.Path) != "api/v1/activitypub/user-id" && strings.ToLower(id.Path) != "api/activitypub/user-id" {
 | 
			
		||||
			result = append(result, fmt.Sprintf("path: %q has to be a person specific api path", id.Path))
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return result
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ----------------------------- RepositoryID --------------------------------------------
 | 
			
		||||
 | 
			
		||||
type RepositoryID struct {
 | 
			
		||||
	ActorID
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Factory function for RepositoryID. Created struct is asserted to be valid.
 | 
			
		||||
func NewRepositoryID(uri, source string) (RepositoryID, error) {
 | 
			
		||||
	if !validation.IsAPIURL(uri) {
 | 
			
		||||
		return RepositoryID{}, fmt.Errorf("uri %s is not a valid repo url on this host %s", uri, setting.AppURL+"api")
 | 
			
		||||
	}
 | 
			
		||||
	result, err := newActorID(uri)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return RepositoryID{}, err
 | 
			
		||||
	}
 | 
			
		||||
	result.Source = source
 | 
			
		||||
 | 
			
		||||
	// validate Person specific path
 | 
			
		||||
	repoID := RepositoryID{result}
 | 
			
		||||
	if valid, outcome := validation.IsValid(repoID); !valid {
 | 
			
		||||
		return RepositoryID{}, outcome
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return repoID, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (id RepositoryID) Validate() []string {
 | 
			
		||||
	result := id.ActorID.Validate()
 | 
			
		||||
	result = append(result, validation.ValidateNotEmpty(id.Source, "source")...)
 | 
			
		||||
	result = append(result, validation.ValidateOneOf(id.Source, []any{"forgejo", "gitea"}, "Source")...)
 | 
			
		||||
	switch id.Source {
 | 
			
		||||
	case "forgejo", "gitea":
 | 
			
		||||
		if strings.ToLower(id.Path) != "api/v1/activitypub/repository-id" && strings.ToLower(id.Path) != "api/activitypub/repository-id" {
 | 
			
		||||
			result = append(result, fmt.Sprintf("path: %q has to be a repo specific api path", id.Path))
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return result
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func containsEmptyString(ar []string) bool {
 | 
			
		||||
	for _, elem := range ar {
 | 
			
		||||
		if elem == "" {
 | 
			
		||||
			return true
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return false
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func removeEmptyStrings(ls []string) []string {
 | 
			
		||||
	var rs []string
 | 
			
		||||
	for _, str := range ls {
 | 
			
		||||
		if str != "" {
 | 
			
		||||
			rs = append(rs, str)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return rs
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func newActorID(uri string) (ActorID, error) {
 | 
			
		||||
	validatedURI, err := url.ParseRequestURI(uri)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return ActorID{}, err
 | 
			
		||||
	}
 | 
			
		||||
	pathWithActorID := strings.Split(validatedURI.Path, "/")
 | 
			
		||||
	if containsEmptyString(pathWithActorID) {
 | 
			
		||||
		pathWithActorID = removeEmptyStrings(pathWithActorID)
 | 
			
		||||
	}
 | 
			
		||||
	length := len(pathWithActorID)
 | 
			
		||||
	pathWithoutActorID := strings.Join(pathWithActorID[0:length-1], "/")
 | 
			
		||||
	id := pathWithActorID[length-1]
 | 
			
		||||
 | 
			
		||||
	result := ActorID{}
 | 
			
		||||
	result.ID = id
 | 
			
		||||
	result.Schema = validatedURI.Scheme
 | 
			
		||||
	result.Host = validatedURI.Hostname()
 | 
			
		||||
	result.Path = pathWithoutActorID
 | 
			
		||||
	result.Port = validatedURI.Port()
 | 
			
		||||
	result.UnvalidatedInput = uri
 | 
			
		||||
	return result, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ----------------------------- ForgePerson -------------------------------------
 | 
			
		||||
 | 
			
		||||
// ForgePerson activity data type
 | 
			
		||||
// swagger:model
 | 
			
		||||
type ForgePerson struct {
 | 
			
		||||
	// swagger:ignore
 | 
			
		||||
	ap.Actor
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (s ForgePerson) MarshalJSON() ([]byte, error) {
 | 
			
		||||
	return s.Actor.MarshalJSON()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (s *ForgePerson) UnmarshalJSON(data []byte) error {
 | 
			
		||||
	return s.Actor.UnmarshalJSON(data)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (s ForgePerson) Validate() []string {
 | 
			
		||||
	var result []string
 | 
			
		||||
	result = append(result, validation.ValidateNotEmpty(string(s.Type), "Type")...)
 | 
			
		||||
	result = append(result, validation.ValidateOneOf(string(s.Type), []any{string(ap.PersonType)}, "Type")...)
 | 
			
		||||
	result = append(result, validation.ValidateNotEmpty(s.PreferredUsername.String(), "PreferredUsername")...)
 | 
			
		||||
 | 
			
		||||
	return result
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										223
									
								
								modules/forgefed/actor_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										223
									
								
								modules/forgefed/actor_test.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,223 @@
 | 
			
		|||
// Copyright 2023, 2024 The Forgejo Authors. All rights reserved.
 | 
			
		||||
// SPDX-License-Identifier: MIT
 | 
			
		||||
 | 
			
		||||
package forgefed
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"reflect"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"testing"
 | 
			
		||||
 | 
			
		||||
	"code.gitea.io/gitea/modules/setting"
 | 
			
		||||
	"code.gitea.io/gitea/modules/validation"
 | 
			
		||||
 | 
			
		||||
	ap "github.com/go-ap/activitypub"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func TestNewPersonId(t *testing.T) {
 | 
			
		||||
	expected := PersonID{}
 | 
			
		||||
	expected.ID = "1"
 | 
			
		||||
	expected.Source = "forgejo"
 | 
			
		||||
	expected.Schema = "https"
 | 
			
		||||
	expected.Path = "api/v1/activitypub/user-id"
 | 
			
		||||
	expected.Host = "an.other.host"
 | 
			
		||||
	expected.Port = ""
 | 
			
		||||
	expected.UnvalidatedInput = "https://an.other.host/api/v1/activitypub/user-id/1"
 | 
			
		||||
	sut, _ := NewPersonID("https://an.other.host/api/v1/activitypub/user-id/1", "forgejo")
 | 
			
		||||
	if sut != expected {
 | 
			
		||||
		t.Errorf("expected: %v\n but was: %v\n", expected, sut)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	expected = PersonID{}
 | 
			
		||||
	expected.ID = "1"
 | 
			
		||||
	expected.Source = "forgejo"
 | 
			
		||||
	expected.Schema = "https"
 | 
			
		||||
	expected.Path = "api/v1/activitypub/user-id"
 | 
			
		||||
	expected.Host = "an.other.host"
 | 
			
		||||
	expected.Port = "443"
 | 
			
		||||
	expected.UnvalidatedInput = "https://an.other.host:443/api/v1/activitypub/user-id/1"
 | 
			
		||||
	sut, _ = NewPersonID("https://an.other.host:443/api/v1/activitypub/user-id/1", "forgejo")
 | 
			
		||||
	if sut != expected {
 | 
			
		||||
		t.Errorf("expected: %v\n but was: %v\n", expected, sut)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestNewRepositoryId(t *testing.T) {
 | 
			
		||||
	setting.AppURL = "http://localhost:3000/"
 | 
			
		||||
	expected := RepositoryID{}
 | 
			
		||||
	expected.ID = "1"
 | 
			
		||||
	expected.Source = "forgejo"
 | 
			
		||||
	expected.Schema = "http"
 | 
			
		||||
	expected.Path = "api/activitypub/repository-id"
 | 
			
		||||
	expected.Host = "localhost"
 | 
			
		||||
	expected.Port = "3000"
 | 
			
		||||
	expected.UnvalidatedInput = "http://localhost:3000/api/activitypub/repository-id/1"
 | 
			
		||||
	sut, _ := NewRepositoryID("http://localhost:3000/api/activitypub/repository-id/1", "forgejo")
 | 
			
		||||
	if sut != expected {
 | 
			
		||||
		t.Errorf("expected: %v\n but was: %v\n", expected, sut)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestActorIdValidation(t *testing.T) {
 | 
			
		||||
	sut := ActorID{}
 | 
			
		||||
	sut.Source = "forgejo"
 | 
			
		||||
	sut.Schema = "https"
 | 
			
		||||
	sut.Path = "api/v1/activitypub/user-id"
 | 
			
		||||
	sut.Host = "an.other.host"
 | 
			
		||||
	sut.Port = ""
 | 
			
		||||
	sut.UnvalidatedInput = "https://an.other.host/api/v1/activitypub/user-id/"
 | 
			
		||||
	if sut.Validate()[0] != "userId should not be empty" {
 | 
			
		||||
		t.Errorf("validation error expected but was: %v\n", sut.Validate())
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	sut = ActorID{}
 | 
			
		||||
	sut.ID = "1"
 | 
			
		||||
	sut.Source = "forgejo"
 | 
			
		||||
	sut.Schema = "https"
 | 
			
		||||
	sut.Path = "api/v1/activitypub/user-id"
 | 
			
		||||
	sut.Host = "an.other.host"
 | 
			
		||||
	sut.Port = ""
 | 
			
		||||
	sut.UnvalidatedInput = "https://an.other.host/api/v1/activitypub/user-id/1?illegal=action"
 | 
			
		||||
	if sut.Validate()[0] != "not all input was parsed, \nUnvalidated Input:\"https://an.other.host/api/v1/activitypub/user-id/1?illegal=action\" \nParsed URI: \"https://an.other.host/api/v1/activitypub/user-id/1\"" {
 | 
			
		||||
		t.Errorf("validation error expected but was: %v\n", sut.Validate()[0])
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestPersonIdValidation(t *testing.T) {
 | 
			
		||||
	sut := PersonID{}
 | 
			
		||||
	sut.ID = "1"
 | 
			
		||||
	sut.Source = "forgejo"
 | 
			
		||||
	sut.Schema = "https"
 | 
			
		||||
	sut.Path = "path"
 | 
			
		||||
	sut.Host = "an.other.host"
 | 
			
		||||
	sut.Port = ""
 | 
			
		||||
	sut.UnvalidatedInput = "https://an.other.host/path/1"
 | 
			
		||||
	if _, err := validation.IsValid(sut); err.Error() != "path: \"path\" has to be a person specific api path" {
 | 
			
		||||
		t.Errorf("validation error expected but was: %v\n", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	sut = PersonID{}
 | 
			
		||||
	sut.ID = "1"
 | 
			
		||||
	sut.Source = "forgejox"
 | 
			
		||||
	sut.Schema = "https"
 | 
			
		||||
	sut.Path = "api/v1/activitypub/user-id"
 | 
			
		||||
	sut.Host = "an.other.host"
 | 
			
		||||
	sut.Port = ""
 | 
			
		||||
	sut.UnvalidatedInput = "https://an.other.host/api/v1/activitypub/user-id/1"
 | 
			
		||||
	if sut.Validate()[0] != "Value forgejox is not contained in allowed values [forgejo gitea]" {
 | 
			
		||||
		t.Errorf("validation error expected but was: %v\n", sut.Validate()[0])
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestWebfingerId(t *testing.T) {
 | 
			
		||||
	sut, _ := NewPersonID("https://codeberg.org/api/v1/activitypub/user-id/12345", "forgejo")
 | 
			
		||||
	if sut.AsWebfinger() != "@12345@codeberg.org" {
 | 
			
		||||
		t.Errorf("wrong webfinger: %v", sut.AsWebfinger())
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	sut, _ = NewPersonID("https://Codeberg.org/api/v1/activitypub/user-id/12345", "forgejo")
 | 
			
		||||
	if sut.AsWebfinger() != "@12345@codeberg.org" {
 | 
			
		||||
		t.Errorf("wrong webfinger: %v", sut.AsWebfinger())
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestShouldThrowErrorOnInvalidInput(t *testing.T) {
 | 
			
		||||
	var err any
 | 
			
		||||
	// TODO: remove after test
 | 
			
		||||
	//_, err = NewPersonId("", "forgejo")
 | 
			
		||||
	//if err == nil {
 | 
			
		||||
	//	t.Errorf("empty input should be invalid.")
 | 
			
		||||
	//}
 | 
			
		||||
 | 
			
		||||
	_, err = NewPersonID("http://localhost:3000/api/v1/something", "forgejo")
 | 
			
		||||
	if err == nil {
 | 
			
		||||
		t.Errorf("localhost uris are not external")
 | 
			
		||||
	}
 | 
			
		||||
	_, err = NewPersonID("./api/v1/something", "forgejo")
 | 
			
		||||
	if err == nil {
 | 
			
		||||
		t.Errorf("relative uris are not allowed")
 | 
			
		||||
	}
 | 
			
		||||
	_, err = NewPersonID("http://1.2.3.4/api/v1/something", "forgejo")
 | 
			
		||||
	if err == nil {
 | 
			
		||||
		t.Errorf("uri may not be ip-4 based")
 | 
			
		||||
	}
 | 
			
		||||
	_, err = NewPersonID("http:///[fe80::1ff:fe23:4567:890a%25eth0]/api/v1/something", "forgejo")
 | 
			
		||||
	if err == nil {
 | 
			
		||||
		t.Errorf("uri may not be ip-6 based")
 | 
			
		||||
	}
 | 
			
		||||
	_, err = NewPersonID("https://codeberg.org/api/v1/activitypub/../activitypub/user-id/12345", "forgejo")
 | 
			
		||||
	if err == nil {
 | 
			
		||||
		t.Errorf("uri may not contain relative path elements")
 | 
			
		||||
	}
 | 
			
		||||
	_, err = NewPersonID("https://myuser@an.other.host/api/v1/activitypub/user-id/1", "forgejo")
 | 
			
		||||
	if err == nil {
 | 
			
		||||
		t.Errorf("uri may not contain unparsed elements")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	_, err = NewPersonID("https://an.other.host/api/v1/activitypub/user-id/1", "forgejo")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Errorf("this uri should be valid but was: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func Test_PersonMarshalJSON(t *testing.T) {
 | 
			
		||||
	sut := ForgePerson{}
 | 
			
		||||
	sut.Type = "Person"
 | 
			
		||||
	sut.PreferredUsername = ap.NaturalLanguageValuesNew()
 | 
			
		||||
	sut.PreferredUsername.Set("en", ap.Content("MaxMuster"))
 | 
			
		||||
	result, _ := sut.MarshalJSON()
 | 
			
		||||
	if string(result) != "{\"type\":\"Person\",\"preferredUsername\":\"MaxMuster\"}" {
 | 
			
		||||
		t.Errorf("MarshalJSON() was = %q", result)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func Test_PersonUnmarshalJSON(t *testing.T) {
 | 
			
		||||
	expected := &ForgePerson{
 | 
			
		||||
		Actor: ap.Actor{
 | 
			
		||||
			Type: "Person",
 | 
			
		||||
			PreferredUsername: ap.NaturalLanguageValues{
 | 
			
		||||
				ap.LangRefValue{Ref: "en", Value: []byte("MaxMuster")},
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
	sut := new(ForgePerson)
 | 
			
		||||
	err := sut.UnmarshalJSON([]byte(`{"type":"Person","preferredUsername":"MaxMuster"}`))
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Errorf("UnmarshalJSON() unexpected error: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	x, _ := expected.MarshalJSON()
 | 
			
		||||
	y, _ := sut.MarshalJSON()
 | 
			
		||||
	if !reflect.DeepEqual(x, y) {
 | 
			
		||||
		t.Errorf("UnmarshalJSON() expected: %q got: %q", x, y)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	expectedStr := strings.ReplaceAll(strings.ReplaceAll(`{
 | 
			
		||||
		"id":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/10",
 | 
			
		||||
		"type":"Person",
 | 
			
		||||
		"icon":{"type":"Image","mediaType":"image/png","url":"https://federated-repo.prod.meissa.de/avatar/fa7f9c4af2a64f41b1bef292bf872614"},
 | 
			
		||||
		"url":"https://federated-repo.prod.meissa.de/stargoose9",
 | 
			
		||||
		"inbox":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/10/inbox",
 | 
			
		||||
		"outbox":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/10/outbox",
 | 
			
		||||
		"preferredUsername":"stargoose9",
 | 
			
		||||
		"publicKey":{"id":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/10#main-key",
 | 
			
		||||
			"owner":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/10",
 | 
			
		||||
			"publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBoj...XAgMBAAE=\n-----END PUBLIC KEY-----\n"}}`,
 | 
			
		||||
		"\n", ""),
 | 
			
		||||
		"\t", "")
 | 
			
		||||
	err = sut.UnmarshalJSON([]byte(expectedStr))
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Errorf("UnmarshalJSON() unexpected error: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	result, _ := sut.MarshalJSON()
 | 
			
		||||
	if expectedStr != string(result) {
 | 
			
		||||
		t.Errorf("UnmarshalJSON() expected: %q got: %q", expectedStr, result)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestForgePersonValidation(t *testing.T) {
 | 
			
		||||
	sut := new(ForgePerson)
 | 
			
		||||
	sut.UnmarshalJSON([]byte(`{"type":"Person","preferredUsername":"MaxMuster"}`))
 | 
			
		||||
	if res, _ := validation.IsValid(sut); !res {
 | 
			
		||||
		t.Errorf("sut expected to be valid: %v\n", sut.Validate())
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										19
									
								
								modules/forgefed/nodeinfo.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								modules/forgefed/nodeinfo.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,19 @@
 | 
			
		|||
// Copyright 2023 The Forgejo Authors. All rights reserved.
 | 
			
		||||
// SPDX-License-Identifier: MIT
 | 
			
		||||
 | 
			
		||||
package forgefed
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func (id ActorID) AsWellKnownNodeInfoURI() string {
 | 
			
		||||
	wellKnownPath := ".well-known/nodeinfo"
 | 
			
		||||
	var result string
 | 
			
		||||
	if id.Port == "" {
 | 
			
		||||
		result = fmt.Sprintf("%s://%s/%s", id.Schema, id.Host, wellKnownPath)
 | 
			
		||||
	} else {
 | 
			
		||||
		result = fmt.Sprintf("%s://%s:%s/%s", id.Schema, id.Host, id.Port, wellKnownPath)
 | 
			
		||||
	}
 | 
			
		||||
	return result
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -5,8 +5,12 @@ package federation
 | 
			
		|||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"net/http"
 | 
			
		||||
 | 
			
		||||
	"code.gitea.io/gitea/models/forgefed"
 | 
			
		||||
	"code.gitea.io/gitea/models/user"
 | 
			
		||||
	"code.gitea.io/gitea/modules/activitypub"
 | 
			
		||||
	fm "code.gitea.io/gitea/modules/forgefed"
 | 
			
		||||
	"code.gitea.io/gitea/modules/log"
 | 
			
		||||
	"code.gitea.io/gitea/modules/validation"
 | 
			
		||||
| 
						 | 
				
			
			@ -26,5 +30,69 @@ func ProcessLikeActivity(ctx context.Context, form any, repositoryID int64) (int
 | 
			
		|||
	}
 | 
			
		||||
	log.Info("Activity validated:%v", activity)
 | 
			
		||||
 | 
			
		||||
	// parse actorID (person)
 | 
			
		||||
	actorURI := activity.Actor.GetID().String()
 | 
			
		||||
	log.Info("actorURI was: %v", actorURI)
 | 
			
		||||
	federationHost, err := GetFederationHostForURI(ctx, actorURI)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return http.StatusInternalServerError, "Wrong FederationHost", err
 | 
			
		||||
	}
 | 
			
		||||
	if !activity.IsNewer(federationHost.LatestActivity) {
 | 
			
		||||
		return http.StatusNotAcceptable, "Activity out of order.", fmt.Errorf("Activity already processed")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return 0, "", nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func CreateFederationHostFromAP(ctx context.Context, actorID fm.ActorID) (*forgefed.FederationHost, error) {
 | 
			
		||||
	actionsUser := user.NewActionsUser()
 | 
			
		||||
	client, err := activitypub.NewClient(ctx, actionsUser, "no idea where to get key material.")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	body, err := client.GetBody(actorID.AsWellKnownNodeInfoURI())
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	nodeInfoWellKnown, err := forgefed.NewNodeInfoWellKnown(body)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	body, err = client.GetBody(nodeInfoWellKnown.Href)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	nodeInfo, err := forgefed.NewNodeInfo(body)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	result, err := forgefed.NewFederationHost(nodeInfo, actorID.Host)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	err = forgefed.CreateFederationHost(ctx, &result)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	return &result, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func GetFederationHostForURI(ctx context.Context, actorURI string) (*forgefed.FederationHost, error) {
 | 
			
		||||
	log.Info("Input was: %v", actorURI)
 | 
			
		||||
	rawActorID, err := fm.NewActorID(actorURI)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	federationHost, err := forgefed.FindFederationHostByFqdn(ctx, rawActorID.Host)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	if federationHost == nil {
 | 
			
		||||
		result, err := CreateFederationHostFromAP(ctx, rawActorID)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return nil, err
 | 
			
		||||
		}
 | 
			
		||||
		federationHost = result
 | 
			
		||||
	}
 | 
			
		||||
	return federationHost, nil
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue