next step on the way to federation
This commit is contained in:
parent
99d1ae52fc
commit
1a76664d56
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2024 The Forgejo Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
// TODO: Think about whether this should be moved to services/activitypub (compare to exosy/services/activitypub/client.go)
|
||||||
package activitypub
|
package activitypub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
@ -10,11 +12,13 @@ import (
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
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/proxy"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"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{
|
Transport: &http.Transport{
|
||||||
Proxy: proxy.Proxy(),
|
Proxy: proxy.Proxy(),
|
||||||
},
|
},
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
},
|
},
|
||||||
algs: setting.HttpsigAlgs,
|
algs: setting.HttpsigAlgs,
|
||||||
digestAlg: httpsig.DigestAlgorithm(setting.Federation.DigestAlgorithm),
|
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
|
// 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)
|
buf := bytes.NewBuffer(b)
|
||||||
req, err = http.NewRequest(http.MethodPost, to, buf)
|
req, err = http.NewRequest(method, to, buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -116,9 +121,52 @@ func (c *Client) NewRequest(b []byte, to string) (req *http.Request, err error)
|
||||||
// Post function
|
// Post function
|
||||||
func (c *Client) Post(b []byte, to string) (resp *http.Response, err error) {
|
func (c *Client) Post(b []byte, to string) (resp *http.Response, err error) {
|
||||||
var req *http.Request
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
resp, err = c.client.Do(req)
|
resp, err = c.client.Do(req)
|
||||||
return resp, err
|
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 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Copyright 2023 The Forgejo Authors. All rights reserved.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package activitypub
|
package activitypub
|
||||||
|
@ -14,11 +15,87 @@ import (
|
||||||
"code.gitea.io/gitea/models/db"
|
"code.gitea.io/gitea/models/db"
|
||||||
"code.gitea.io/gitea/models/unittest"
|
"code.gitea.io/gitea/models/unittest"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
|
"code.gitea.io/gitea/modules/log"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"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) {
|
func TestActivityPubSignedPost(t *testing.T) {
|
||||||
assert.NoError(t, unittest.PrepareTestDatabase())
|
assert.NoError(t, unittest.PrepareTestDatabase())
|
||||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"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"
|
fm "code.gitea.io/gitea/modules/forgefed"
|
||||||
"code.gitea.io/gitea/modules/log"
|
"code.gitea.io/gitea/modules/log"
|
||||||
"code.gitea.io/gitea/modules/validation"
|
"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)
|
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
|
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…
Reference in New Issue