mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2024-12-27 14:23:56 +01:00
Merge pull request 'Federation: Parse ActorId & cache FederationHost' (#3662) from meissa/forgejo:forgejo-federated-parse-actorId into forgejo
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/3662 Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
This commit is contained in:
commit
77092c7a0c
17 changed files with 1202 additions and 14 deletions
|
@ -43,6 +43,10 @@ package "code.gitea.io/gitea/models/dbfs"
|
|||
func Create
|
||||
func Rename
|
||||
|
||||
package "code.gitea.io/gitea/models/forgefed"
|
||||
func GetFederationHost
|
||||
func UpdateFederationHost
|
||||
|
||||
package "code.gitea.io/gitea/models/forgejo/semver"
|
||||
func GetVersion
|
||||
func SetVersionString
|
||||
|
@ -134,12 +138,7 @@ package "code.gitea.io/gitea/models/user"
|
|||
func GetUserNamesByIDs
|
||||
|
||||
package "code.gitea.io/gitea/modules/activitypub"
|
||||
func CurrentTime
|
||||
func containsRequiredHTTPHeaders
|
||||
func NewClient
|
||||
func (*Client).NewRequest
|
||||
func (*Client).Post
|
||||
func GetPrivateKey
|
||||
|
||||
package "code.gitea.io/gitea/modules/assetfs"
|
||||
func Bindata
|
||||
|
@ -170,6 +169,16 @@ package "code.gitea.io/gitea/modules/eventsource"
|
|||
|
||||
package "code.gitea.io/gitea/modules/forgefed"
|
||||
func NewForgeLike
|
||||
func NewPersonID
|
||||
func (PersonID).AsWebfinger
|
||||
func (PersonID).AsLoginName
|
||||
func (PersonID).HostSuffix
|
||||
func (PersonID).Validate
|
||||
func NewRepositoryID
|
||||
func (RepositoryID).Validate
|
||||
func (ForgePerson).MarshalJSON
|
||||
func (*ForgePerson).UnmarshalJSON
|
||||
func (ForgePerson).Validate
|
||||
func GetItemByType
|
||||
func JSONUnmarshalerFn
|
||||
func NotEmpty
|
||||
|
@ -310,7 +319,7 @@ package "code.gitea.io/gitea/modules/util/filebuffer"
|
|||
func CreateFromReader
|
||||
|
||||
package "code.gitea.io/gitea/modules/validation"
|
||||
func ValidateMaxLen
|
||||
func IsErrNotValid
|
||||
|
||||
package "code.gitea.io/gitea/modules/web"
|
||||
func RouteMock
|
||||
|
|
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"`
|
||||
Created timeutil.TimeStamp `xorm:"created"`
|
||||
Updated timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
// Factory function for FederationHost. 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 cannot be in the far future: %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, 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, err
|
||||
}
|
||||
return host, nil
|
||||
}
|
||||
|
||||
func CreateFederationHost(ctx context.Context, host *FederationHost) error {
|
||||
if res, err := validation.IsValid(host); !res {
|
||||
return 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 err
|
||||
}
|
||||
_, err := db.GetEngine(ctx).ID(host.ID).Update(host)
|
||||
return err
|
||||
}
|
78
models/forgefed/federationhost_test.go
Normal file
78
models/forgefed/federationhost_test.go
Normal file
|
@ -0,0 +1,78 @@
|
|||
// Copyright 2024 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package forgefed
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"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: "",
|
||||
NodeInfo: NodeInfo{
|
||||
SoftwareName: "forgejo",
|
||||
},
|
||||
LatestActivity: time.Now(),
|
||||
}
|
||||
if res, _ := validation.IsValid(sut); res {
|
||||
t.Errorf("sut should be invalid: HostFqdn empty")
|
||||
}
|
||||
|
||||
sut = FederationHost{
|
||||
HostFqdn: strings.Repeat("fill", 64),
|
||||
NodeInfo: NodeInfo{
|
||||
SoftwareName: "forgejo",
|
||||
},
|
||||
LatestActivity: time.Now(),
|
||||
}
|
||||
if res, _ := validation.IsValid(sut); res {
|
||||
t.Errorf("sut should be invalid: HostFqdn too long (len=256)")
|
||||
}
|
||||
|
||||
sut = FederationHost{
|
||||
HostFqdn: "host.do.main",
|
||||
NodeInfo: NodeInfo{},
|
||||
LatestActivity: time.Now(),
|
||||
}
|
||||
if res, _ := validation.IsValid(sut); res {
|
||||
t.Errorf("sut should be invalid: NodeInfo 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 NodeInfoWellKnown. 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
|
||||
}
|
92
models/forgefed/nodeinfo_test.go
Normal file
92
models/forgefed/nodeinfo_test.go
Normal file
|
@ -0,0 +1,92 @@
|
|||
// Copyright 2023 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package forgefed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"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"}
|
||||
_, err := validation.IsValid(sut)
|
||||
if !validation.IsErrNotValid(err) && strings.Contains(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"}
|
||||
_, err = validation.IsValid(sut)
|
||||
if !validation.IsErrNotValid(err) && strings.Contains(err.Error(), "Href has to be absolute\nValue is not contained in allowed values [http https]") {
|
||||
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")
|
||||
}
|
||||
}
|
|
@ -66,6 +66,8 @@ var migrations = []*Migration{
|
|||
NewMigration("Add `hide_archive_links` column to `release` table", AddHideArchiveLinksToRelease),
|
||||
// v14 -> v15
|
||||
NewMigration("Remove Gitea-specific columns from the repository and badge tables", RemoveGiteaSpecificColumnsFromRepositoryAndBadge),
|
||||
// v15 -> v16
|
||||
NewMigration("Create the `federation_host` table", CreateFederationHostTable),
|
||||
}
|
||||
|
||||
// GetCurrentDBVersion returns the current Forgejo database version.
|
||||
|
|
33
models/forgejo_migrations/v15.go
Normal file
33
models/forgejo_migrations/v15.go
Normal file
|
@ -0,0 +1,33 @@
|
|||
// Copyright 2024 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package forgejo_migrations //nolint:revive
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
type (
|
||||
SoftwareNameType string
|
||||
)
|
||||
|
||||
type NodeInfo struct {
|
||||
SoftwareName SoftwareNameType
|
||||
}
|
||||
|
||||
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"`
|
||||
Created timeutil.TimeStamp `xorm:"created"`
|
||||
Updated timeutil.TimeStamp `xorm:"updated"`
|
||||
}
|
||||
|
||||
func CreateFederationHostTable(x *xorm.Engine) error {
|
||||
return x.Sync(new(FederationHost))
|
||||
}
|
|
@ -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) {
|
||||
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, err := validation.IsValid(result); !valid {
|
||||
return ActorID{}, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (id ActorID) AsURI() string {
|
||||
var result string
|
||||
if id.Port == "" {
|
||||
result = fmt.Sprintf("%s://%s/%s/%s", id.Schema, id.Host, id.Path, id.ID)
|
||||
} else {
|
||||
result = fmt.Sprintf("%s://%s:%s/%s/%s", id.Schema, id.Host, id.Port, id.Path, id.ID)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (id ActorID) Validate() []string {
|
||||
var result []string
|
||||
result = append(result, validation.ValidateNotEmpty(id.ID, "userId")...)
|
||||
result = append(result, validation.ValidateNotEmpty(id.Schema, "schema")...)
|
||||
result = append(result, validation.ValidateNotEmpty(id.Path, "path")...)
|
||||
result = append(result, validation.ValidateNotEmpty(id.Host, "host")...)
|
||||
result = append(result, validation.ValidateNotEmpty(id.UnvalidatedInput, "unvalidatedInput")...)
|
||||
|
||||
if id.UnvalidatedInput != id.AsURI() {
|
||||
result = append(result, fmt.Sprintf("not all input was parsed, \nUnvalidated Input:%q \nParsed URI: %q", id.UnvalidatedInput, id.AsURI()))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ----------------------------- PersonID --------------------------------------------
|
||||
type PersonID struct {
|
||||
ActorID
|
||||
}
|
||||
|
||||
// Factory function for PersonID. Created struct is asserted to be valid
|
||||
func NewPersonID(uri, source string) (PersonID, error) {
|
||||
// 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, err := validation.IsValid(personID); !valid {
|
||||
return PersonID{}, err
|
||||
}
|
||||
|
||||
return personID, nil
|
||||
}
|
||||
|
||||
func (id PersonID) AsWebfinger() string {
|
||||
result := fmt.Sprintf("@%s@%s", strings.ToLower(id.ID), strings.ToLower(id.Host))
|
||||
return result
|
||||
}
|
||||
|
||||
func (id PersonID) AsLoginName() string {
|
||||
result := fmt.Sprintf("%s%s", strings.ToLower(id.ID), id.HostSuffix())
|
||||
return result
|
||||
}
|
||||
|
||||
func (id PersonID) HostSuffix() string {
|
||||
result := fmt.Sprintf("-%s", strings.ToLower(id.Host))
|
||||
return result
|
||||
}
|
||||
|
||||
func (id PersonID) Validate() []string {
|
||||
result := id.ActorID.Validate()
|
||||
result = append(result, validation.ValidateNotEmpty(id.Source, "source")...)
|
||||
result = append(result, validation.ValidateOneOf(id.Source, []any{"forgejo", "gitea"}, "Source")...)
|
||||
switch id.Source {
|
||||
case "forgejo", "gitea":
|
||||
if strings.ToLower(id.Path) != "api/v1/activitypub/user-id" && strings.ToLower(id.Path) != "api/activitypub/user-id" {
|
||||
result = append(result, fmt.Sprintf("path: %q has to be a person specific api path", id.Path))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ----------------------------- RepositoryID --------------------------------------------
|
||||
|
||||
type RepositoryID struct {
|
||||
ActorID
|
||||
}
|
||||
|
||||
// Factory function for RepositoryID. Created struct is asserted to be valid.
|
||||
func NewRepositoryID(uri, source string) (RepositoryID, error) {
|
||||
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, err := validation.IsValid(repoID); !valid {
|
||||
return RepositoryID{}, err
|
||||
}
|
||||
|
||||
return repoID, nil
|
||||
}
|
||||
|
||||
func (id RepositoryID) Validate() []string {
|
||||
result := id.ActorID.Validate()
|
||||
result = append(result, validation.ValidateNotEmpty(id.Source, "source")...)
|
||||
result = append(result, validation.ValidateOneOf(id.Source, []any{"forgejo", "gitea"}, "Source")...)
|
||||
switch id.Source {
|
||||
case "forgejo", "gitea":
|
||||
if strings.ToLower(id.Path) != "api/v1/activitypub/repository-id" && strings.ToLower(id.Path) != "api/activitypub/repository-id" {
|
||||
result = append(result, fmt.Sprintf("path: %q has to be a repo specific api path", id.Path))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func containsEmptyString(ar []string) bool {
|
||||
for _, elem := range ar {
|
||||
if elem == "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func removeEmptyStrings(ls []string) []string {
|
||||
var rs []string
|
||||
for _, str := range ls {
|
||||
if str != "" {
|
||||
rs = append(rs, str)
|
||||
}
|
||||
}
|
||||
return rs
|
||||
}
|
||||
|
||||
func newActorID(uri string) (ActorID, error) {
|
||||
validatedURI, err := url.ParseRequestURI(uri)
|
||||
if err != nil {
|
||||
return ActorID{}, err
|
||||
}
|
||||
pathWithActorID := strings.Split(validatedURI.Path, "/")
|
||||
if containsEmptyString(pathWithActorID) {
|
||||
pathWithActorID = removeEmptyStrings(pathWithActorID)
|
||||
}
|
||||
length := len(pathWithActorID)
|
||||
pathWithoutActorID := strings.Join(pathWithActorID[0:length-1], "/")
|
||||
id := pathWithActorID[length-1]
|
||||
|
||||
result := ActorID{}
|
||||
result.ID = id
|
||||
result.Schema = validatedURI.Scheme
|
||||
result.Host = validatedURI.Hostname()
|
||||
result.Path = pathWithoutActorID
|
||||
result.Port = validatedURI.Port()
|
||||
result.UnvalidatedInput = uri
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ----------------------------- ForgePerson -------------------------------------
|
||||
|
||||
// ForgePerson activity data type
|
||||
// swagger:model
|
||||
type ForgePerson struct {
|
||||
// swagger:ignore
|
||||
ap.Actor
|
||||
}
|
||||
|
||||
func (s ForgePerson) MarshalJSON() ([]byte, error) {
|
||||
return s.Actor.MarshalJSON()
|
||||
}
|
||||
|
||||
func (s *ForgePerson) UnmarshalJSON(data []byte) error {
|
||||
return s.Actor.UnmarshalJSON(data)
|
||||
}
|
||||
|
||||
func (s ForgePerson) Validate() []string {
|
||||
var result []string
|
||||
result = append(result, validation.ValidateNotEmpty(string(s.Type), "Type")...)
|
||||
result = append(result, validation.ValidateOneOf(string(s.Type), []any{string(ap.PersonType)}, "Type")...)
|
||||
result = append(result, validation.ValidateNotEmpty(s.PreferredUsername.String(), "PreferredUsername")...)
|
||||
|
||||
return result
|
||||
}
|
225
modules/forgefed/actor_test.go
Normal file
225
modules/forgefed/actor_test.go
Normal file
|
@ -0,0 +1,225 @@
|
|||
// Copyright 2023, 2024 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package forgefed
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/validation"
|
||||
|
||||
ap "github.com/go-ap/activitypub"
|
||||
)
|
||||
|
||||
func TestNewPersonId(t *testing.T) {
|
||||
expected := PersonID{}
|
||||
expected.ID = "1"
|
||||
expected.Source = "forgejo"
|
||||
expected.Schema = "https"
|
||||
expected.Path = "api/v1/activitypub/user-id"
|
||||
expected.Host = "an.other.host"
|
||||
expected.Port = ""
|
||||
expected.UnvalidatedInput = "https://an.other.host/api/v1/activitypub/user-id/1"
|
||||
sut, _ := NewPersonID("https://an.other.host/api/v1/activitypub/user-id/1", "forgejo")
|
||||
if sut != expected {
|
||||
t.Errorf("expected: %v\n but was: %v\n", expected, sut)
|
||||
}
|
||||
|
||||
expected = PersonID{}
|
||||
expected.ID = "1"
|
||||
expected.Source = "forgejo"
|
||||
expected.Schema = "https"
|
||||
expected.Path = "api/v1/activitypub/user-id"
|
||||
expected.Host = "an.other.host"
|
||||
expected.Port = "443"
|
||||
expected.UnvalidatedInput = "https://an.other.host:443/api/v1/activitypub/user-id/1"
|
||||
sut, _ = NewPersonID("https://an.other.host:443/api/v1/activitypub/user-id/1", "forgejo")
|
||||
if sut != expected {
|
||||
t.Errorf("expected: %v\n but was: %v\n", expected, sut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRepositoryId(t *testing.T) {
|
||||
setting.AppURL = "http://localhost:3000/"
|
||||
expected := RepositoryID{}
|
||||
expected.ID = "1"
|
||||
expected.Source = "forgejo"
|
||||
expected.Schema = "http"
|
||||
expected.Path = "api/activitypub/repository-id"
|
||||
expected.Host = "localhost"
|
||||
expected.Port = "3000"
|
||||
expected.UnvalidatedInput = "http://localhost:3000/api/activitypub/repository-id/1"
|
||||
sut, _ := NewRepositoryID("http://localhost:3000/api/activitypub/repository-id/1", "forgejo")
|
||||
if sut != expected {
|
||||
t.Errorf("expected: %v\n but was: %v\n", expected, sut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActorIdValidation(t *testing.T) {
|
||||
sut := ActorID{}
|
||||
sut.Source = "forgejo"
|
||||
sut.Schema = "https"
|
||||
sut.Path = "api/v1/activitypub/user-id"
|
||||
sut.Host = "an.other.host"
|
||||
sut.Port = ""
|
||||
sut.UnvalidatedInput = "https://an.other.host/api/v1/activitypub/user-id/"
|
||||
if sut.Validate()[0] != "userId should not be empty" {
|
||||
t.Errorf("validation error expected but was: %v\n", sut.Validate())
|
||||
}
|
||||
|
||||
sut = ActorID{}
|
||||
sut.ID = "1"
|
||||
sut.Source = "forgejo"
|
||||
sut.Schema = "https"
|
||||
sut.Path = "api/v1/activitypub/user-id"
|
||||
sut.Host = "an.other.host"
|
||||
sut.Port = ""
|
||||
sut.UnvalidatedInput = "https://an.other.host/api/v1/activitypub/user-id/1?illegal=action"
|
||||
if sut.Validate()[0] != "not all input was parsed, \nUnvalidated Input:\"https://an.other.host/api/v1/activitypub/user-id/1?illegal=action\" \nParsed URI: \"https://an.other.host/api/v1/activitypub/user-id/1\"" {
|
||||
t.Errorf("validation error expected but was: %v\n", sut.Validate()[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonIdValidation(t *testing.T) {
|
||||
sut := PersonID{}
|
||||
sut.ID = "1"
|
||||
sut.Source = "forgejo"
|
||||
sut.Schema = "https"
|
||||
sut.Path = "path"
|
||||
sut.Host = "an.other.host"
|
||||
sut.Port = ""
|
||||
sut.UnvalidatedInput = "https://an.other.host/path/1"
|
||||
|
||||
_, err := validation.IsValid(sut)
|
||||
if validation.IsErrNotValid(err) && strings.Contains(err.Error(), "path: \"path\" has to be a person specific api path\n") {
|
||||
t.Errorf("validation error expected but was: %v\n", err)
|
||||
}
|
||||
|
||||
sut = PersonID{}
|
||||
sut.ID = "1"
|
||||
sut.Source = "forgejox"
|
||||
sut.Schema = "https"
|
||||
sut.Path = "api/v1/activitypub/user-id"
|
||||
sut.Host = "an.other.host"
|
||||
sut.Port = ""
|
||||
sut.UnvalidatedInput = "https://an.other.host/api/v1/activitypub/user-id/1"
|
||||
if sut.Validate()[0] != "Value forgejox is not contained in allowed values [forgejo gitea]" {
|
||||
t.Errorf("validation error expected but was: %v\n", sut.Validate()[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebfingerId(t *testing.T) {
|
||||
sut, _ := NewPersonID("https://codeberg.org/api/v1/activitypub/user-id/12345", "forgejo")
|
||||
if sut.AsWebfinger() != "@12345@codeberg.org" {
|
||||
t.Errorf("wrong webfinger: %v", sut.AsWebfinger())
|
||||
}
|
||||
|
||||
sut, _ = NewPersonID("https://Codeberg.org/api/v1/activitypub/user-id/12345", "forgejo")
|
||||
if sut.AsWebfinger() != "@12345@codeberg.org" {
|
||||
t.Errorf("wrong webfinger: %v", sut.AsWebfinger())
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldThrowErrorOnInvalidInput(t *testing.T) {
|
||||
var err any
|
||||
// TODO: remove after test
|
||||
//_, err = NewPersonId("", "forgejo")
|
||||
//if err == nil {
|
||||
// t.Errorf("empty input should be invalid.")
|
||||
//}
|
||||
|
||||
_, err = NewPersonID("http://localhost:3000/api/v1/something", "forgejo")
|
||||
if err == nil {
|
||||
t.Errorf("localhost uris are not external")
|
||||
}
|
||||
_, err = NewPersonID("./api/v1/something", "forgejo")
|
||||
if err == nil {
|
||||
t.Errorf("relative uris are not allowed")
|
||||
}
|
||||
_, err = NewPersonID("http://1.2.3.4/api/v1/something", "forgejo")
|
||||
if err == nil {
|
||||
t.Errorf("uri may not be ip-4 based")
|
||||
}
|
||||
_, err = NewPersonID("http:///[fe80::1ff:fe23:4567:890a%25eth0]/api/v1/something", "forgejo")
|
||||
if err == nil {
|
||||
t.Errorf("uri may not be ip-6 based")
|
||||
}
|
||||
_, err = NewPersonID("https://codeberg.org/api/v1/activitypub/../activitypub/user-id/12345", "forgejo")
|
||||
if err == nil {
|
||||
t.Errorf("uri may not contain relative path elements")
|
||||
}
|
||||
_, err = NewPersonID("https://myuser@an.other.host/api/v1/activitypub/user-id/1", "forgejo")
|
||||
if err == nil {
|
||||
t.Errorf("uri may not contain unparsed elements")
|
||||
}
|
||||
|
||||
_, err = NewPersonID("https://an.other.host/api/v1/activitypub/user-id/1", "forgejo")
|
||||
if err != nil {
|
||||
t.Errorf("this uri should be valid but was: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_PersonMarshalJSON(t *testing.T) {
|
||||
sut := ForgePerson{}
|
||||
sut.Type = "Person"
|
||||
sut.PreferredUsername = ap.NaturalLanguageValuesNew()
|
||||
sut.PreferredUsername.Set("en", ap.Content("MaxMuster"))
|
||||
result, _ := sut.MarshalJSON()
|
||||
if string(result) != "{\"type\":\"Person\",\"preferredUsername\":\"MaxMuster\"}" {
|
||||
t.Errorf("MarshalJSON() was = %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_PersonUnmarshalJSON(t *testing.T) {
|
||||
expected := &ForgePerson{
|
||||
Actor: ap.Actor{
|
||||
Type: "Person",
|
||||
PreferredUsername: ap.NaturalLanguageValues{
|
||||
ap.LangRefValue{Ref: "en", Value: []byte("MaxMuster")},
|
||||
},
|
||||
},
|
||||
}
|
||||
sut := new(ForgePerson)
|
||||
err := sut.UnmarshalJSON([]byte(`{"type":"Person","preferredUsername":"MaxMuster"}`))
|
||||
if err != nil {
|
||||
t.Errorf("UnmarshalJSON() unexpected error: %v", err)
|
||||
}
|
||||
x, _ := expected.MarshalJSON()
|
||||
y, _ := sut.MarshalJSON()
|
||||
if !reflect.DeepEqual(x, y) {
|
||||
t.Errorf("UnmarshalJSON() expected: %q got: %q", x, y)
|
||||
}
|
||||
|
||||
expectedStr := strings.ReplaceAll(strings.ReplaceAll(`{
|
||||
"id":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/10",
|
||||
"type":"Person",
|
||||
"icon":{"type":"Image","mediaType":"image/png","url":"https://federated-repo.prod.meissa.de/avatar/fa7f9c4af2a64f41b1bef292bf872614"},
|
||||
"url":"https://federated-repo.prod.meissa.de/stargoose9",
|
||||
"inbox":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/10/inbox",
|
||||
"outbox":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/10/outbox",
|
||||
"preferredUsername":"stargoose9",
|
||||
"publicKey":{"id":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/10#main-key",
|
||||
"owner":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/10",
|
||||
"publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBoj...XAgMBAAE=\n-----END PUBLIC KEY-----\n"}}`,
|
||||
"\n", ""),
|
||||
"\t", "")
|
||||
err = sut.UnmarshalJSON([]byte(expectedStr))
|
||||
if err != nil {
|
||||
t.Errorf("UnmarshalJSON() unexpected error: %v", err)
|
||||
}
|
||||
result, _ := sut.MarshalJSON()
|
||||
if expectedStr != string(result) {
|
||||
t.Errorf("UnmarshalJSON() expected: %q got: %q", expectedStr, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForgePersonValidation(t *testing.T) {
|
||||
sut := new(ForgePerson)
|
||||
sut.UnmarshalJSON([]byte(`{"type":"Person","preferredUsername":"MaxMuster"}`))
|
||||
if res, _ := validation.IsValid(sut); !res {
|
||||
t.Errorf("sut expected to be valid: %v\n", sut.Validate())
|
||||
}
|
||||
}
|
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
|
||||
}
|
|
@ -6,20 +6,37 @@ package validation
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
)
|
||||
|
||||
// ErrNotValid represents an validation error
|
||||
type ErrNotValid struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (err ErrNotValid) Error() string {
|
||||
return fmt.Sprintf("Validation Error: %v", err.Message)
|
||||
}
|
||||
|
||||
// IsErrNotValid checks if an error is a ErrNotValid.
|
||||
func IsErrNotValid(err error) bool {
|
||||
_, ok := err.(ErrNotValid)
|
||||
return ok
|
||||
}
|
||||
|
||||
type Validateable interface {
|
||||
Validate() []string
|
||||
}
|
||||
|
||||
func IsValid(v Validateable) (bool, error) {
|
||||
if err := v.Validate(); len(err) > 0 {
|
||||
typeof := reflect.TypeOf(v)
|
||||
errString := strings.Join(err, "\n")
|
||||
return false, fmt.Errorf(errString)
|
||||
return false, ErrNotValid{fmt.Sprint(typeof, ": ", errString)}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
|
|
|
@ -26,9 +26,13 @@ func Test_IsValid(t *testing.T) {
|
|||
t.Errorf("sut expected to be valid: %v\n", sut.Validate())
|
||||
}
|
||||
sut = Sut{valid: false}
|
||||
if res, _ := IsValid(sut); res {
|
||||
res, err := IsValid(sut)
|
||||
if res {
|
||||
t.Errorf("sut expected to be invalid: %v\n", sut.Validate())
|
||||
}
|
||||
if err == nil || !IsErrNotValid(err) || err.Error() != "Validation Error: validation.Sut: invalid" {
|
||||
t.Errorf("validation error expected, but was %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_ValidateNotEmpty_ForString(t *testing.T) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -9,8 +9,11 @@ import (
|
|||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/models/db"
|
||||
"code.gitea.io/gitea/models/forgefed"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
"code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/modules/activitypub"
|
||||
forgefed_modules "code.gitea.io/gitea/modules/forgefed"
|
||||
|
@ -70,6 +73,47 @@ func TestActivityPubRepositoryInboxValid(t *testing.T) {
|
|||
srv := httptest.NewServer(testWebRoutes)
|
||||
defer srv.Close()
|
||||
|
||||
federatedRoutes := http.NewServeMux()
|
||||
federatedRoutes.HandleFunc("/.well-known/nodeinfo",
|
||||
func(res http.ResponseWriter, req *http.Request) {
|
||||
// curl -H "Accept: application/json" https://federated-repo.prod.meissa.de/.well-known/nodeinfo
|
||||
responseBody := fmt.Sprintf(`{"links":[{"href":"http://%s/api/v1/nodeinfo","rel":"http://nodeinfo.diaspora.software/ns/schema/2.1"}]}`, req.Host)
|
||||
t.Logf("response: %s", responseBody)
|
||||
// TODO: as soon as content-type will become important: content-type: application/json;charset=utf-8
|
||||
fmt.Fprint(res, responseBody)
|
||||
})
|
||||
federatedRoutes.HandleFunc("/api/v1/nodeinfo",
|
||||
func(res http.ResponseWriter, req *http.Request) {
|
||||
// curl -H "Accept: application/json" https://federated-repo.prod.meissa.de/api/v1/nodeinfo
|
||||
responseBody := fmt.Sprintf(`{"version":"2.1","software":{"name":"forgejo","version":"1.20.0+dev-3183-g976d79044",` +
|
||||
`"repository":"https://codeberg.org/forgejo/forgejo.git","homepage":"https://forgejo.org/"},` +
|
||||
`"protocols":["activitypub"],"services":{"inbound":[],"outbound":["rss2.0"]},` +
|
||||
`"openRegistrations":true,"usage":{"users":{"total":14,"activeHalfyear":2}},"metadata":{}}`)
|
||||
fmt.Fprint(res, responseBody)
|
||||
})
|
||||
federatedRoutes.HandleFunc("/api/v1/activitypub/user-id/2",
|
||||
func(res http.ResponseWriter, req *http.Request) {
|
||||
// curl -H "Accept: application/json" https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/2
|
||||
responseBody := fmt.Sprintf(`{"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/v1"],` +
|
||||
`"id":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/2","type":"Person",` +
|
||||
`"icon":{"type":"Image","mediaType":"image/png","url":"https://federated-repo.prod.meissa.de/avatars/1bb05d9a5f6675ed0272af9ea193063c"},` +
|
||||
`"url":"https://federated-repo.prod.meissa.de/stargoose1","inbox":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/2/inbox",` +
|
||||
`"outbox":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/2/outbox","preferredUsername":"stargoose1",` +
|
||||
`"publicKey":{"id":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/2#main-key","owner":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/2",` +
|
||||
`"publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEA18H5s7N6ItZUAh9tneII\nIuZdTTa3cZlLa/9ejWAHTkcp3WLW+/zbsumlMrWYfBy2/yTm56qasWt38iY4D6ul\n` +
|
||||
`CPiwhAqX3REvVq8tM79a2CEqZn9ka6vuXoDgBg/sBf/BUWqf7orkjUXwk/U0Egjf\nk5jcurF4vqf1u+rlAHH37dvSBaDjNj6Qnj4OP12bjfaY/yvs7+jue/eNXFHjzN4E\n` +
|
||||
`T2H4B/yeKTJ4UuAwTlLaNbZJul2baLlHelJPAsxiYaziVuV5P+IGWckY6RSerRaZ\nAkc4mmGGtjAyfN9aewe+lNVfwS7ElFx546PlLgdQgjmeSwLX8FWxbPE5A/PmaXCs\n` +
|
||||
`nx+nou+3dD7NluULLtdd7K+2x02trObKXCAzmi5/Dc+yKTzpFqEz+hLNCz7TImP/\ncK//NV9Q+X67J9O27baH9R9ZF4zMw8rv2Pg0WLSw1z7lLXwlgIsDapeMCsrxkVO4\n` +
|
||||
`LXX5AQ1xQNtlssnVoUBqBrvZsX2jUUKUocvZqMGuE4hfAgMBAAE=\n-----END PUBLIC KEY-----\n"}}`)
|
||||
fmt.Fprint(res, responseBody)
|
||||
})
|
||||
federatedRoutes.HandleFunc("/",
|
||||
func(res http.ResponseWriter, req *http.Request) {
|
||||
t.Errorf("Unhandled request: %q", req.URL.EscapedPath())
|
||||
})
|
||||
federatedSrv := httptest.NewServer(federatedRoutes)
|
||||
defer federatedSrv.Close()
|
||||
|
||||
onGiteaRun(t, func(*testing.T, *url.URL) {
|
||||
appURL := setting.AppURL
|
||||
setting.AppURL = srv.URL + "/"
|
||||
|
@ -81,14 +125,24 @@ func TestActivityPubRepositoryInboxValid(t *testing.T) {
|
|||
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",
|
||||
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))
|
||||
activity := []byte(fmt.Sprintf(
|
||||
`{"type":"Like",`+
|
||||
`"startTime":"%s",`+
|
||||
`"actor":"%s/api/v1/activitypub/user-id/2",`+
|
||||
`"object":"%s/api/v1/activitypub/repository-id/%v"}`,
|
||||
time.Now().UTC().Format(time.RFC3339),
|
||||
federatedSrv.URL, srv.URL, repositoryID))
|
||||
t.Logf("activity: %s", activity)
|
||||
resp, err := c.Post(activity, repoInboxURL)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
|
||||
|
||||
unittest.AssertExistsAndLoadBean(t, &forgefed.FederationHost{HostFqdn: "127.0.0.1"})
|
||||
})
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue