From 1a76664d565351898e7322c5629d6e6de44f9388 Mon Sep 17 00:00:00 2001 From: Michael Jerger Date: Tue, 7 May 2024 17:58:13 +0200 Subject: [PATCH 1/6] next step on the way to federation --- models/forgefed/federationhost.go | 52 +++++ models/forgefed/federationhost_repository.go | 61 +++++ models/forgefed/federationhost_test.go | 55 +++++ models/forgefed/nodeinfo.go | 123 ++++++++++ models/forgefed/nodeinfo_test.go | 89 ++++++++ modules/activitypub/client.go | 54 ++++- modules/activitypub/client_test.go | 77 +++++++ modules/forgefed/actor.go | 226 +++++++++++++++++++ modules/forgefed/actor_test.go | 223 ++++++++++++++++++ modules/forgefed/nodeinfo.go | 19 ++ services/federation/federation_service.go | 68 ++++++ 11 files changed, 1044 insertions(+), 3 deletions(-) create mode 100644 models/forgefed/federationhost.go create mode 100644 models/forgefed/federationhost_repository.go create mode 100644 models/forgefed/federationhost_test.go create mode 100644 models/forgefed/nodeinfo.go create mode 100644 models/forgefed/nodeinfo_test.go create mode 100644 modules/forgefed/actor.go create mode 100644 modules/forgefed/actor_test.go create mode 100644 modules/forgefed/nodeinfo.go diff --git a/models/forgefed/federationhost.go b/models/forgefed/federationhost.go new file mode 100644 index 0000000000..eb1183a2a7 --- /dev/null +++ b/models/forgefed/federationhost.go @@ -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 +} diff --git a/models/forgefed/federationhost_repository.go b/models/forgefed/federationhost_repository.go new file mode 100644 index 0000000000..b4e72b0ce1 --- /dev/null +++ b/models/forgefed/federationhost_repository.go @@ -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 +} diff --git a/models/forgefed/federationhost_test.go b/models/forgefed/federationhost_test.go new file mode 100644 index 0000000000..04f941d93d --- /dev/null +++ b/models/forgefed/federationhost_test.go @@ -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") + } +} diff --git a/models/forgefed/nodeinfo.go b/models/forgefed/nodeinfo.go new file mode 100644 index 0000000000..bb56570635 --- /dev/null +++ b/models/forgefed/nodeinfo.go @@ -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 +} diff --git a/models/forgefed/nodeinfo_test.go b/models/forgefed/nodeinfo_test.go new file mode 100644 index 0000000000..ba1bd90be8 --- /dev/null +++ b/models/forgefed/nodeinfo_test.go @@ -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") + } +} diff --git a/modules/activitypub/client.go b/modules/activitypub/client.go index 66b977c01f..4962ad79a4 100644 --- a/modules/activitypub/client.go +++ b/modules/activitypub/client.go @@ -1,6 +1,8 @@ // Copyright 2022 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT +// TODO: Think about whether this should be moved to services/activitypub (compare to exosy/services/activitypub/client.go) package activitypub import ( @@ -10,11 +12,13 @@ import ( "crypto/x509" "encoding/pem" "fmt" + "io" "net/http" "strings" "time" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/proxy" "code.gitea.io/gitea/modules/setting" @@ -84,6 +88,7 @@ func NewClient(ctx context.Context, user *user_model.User, pubID string) (c *Cli Transport: &http.Transport{ Proxy: proxy.Proxy(), }, + Timeout: 5 * time.Second, }, algs: setting.HttpsigAlgs, digestAlg: httpsig.DigestAlgorithm(setting.Federation.DigestAlgorithm), @@ -96,9 +101,9 @@ func NewClient(ctx context.Context, user *user_model.User, pubID string) (c *Cli } // NewRequest function -func (c *Client) NewRequest(b []byte, to string) (req *http.Request, err error) { +func (c *Client) NewRequest(method string, b []byte, to string) (req *http.Request, err error) { buf := bytes.NewBuffer(b) - req, err = http.NewRequest(http.MethodPost, to, buf) + req, err = http.NewRequest(method, to, buf) if err != nil { return nil, err } @@ -116,9 +121,52 @@ func (c *Client) NewRequest(b []byte, to string) (req *http.Request, err error) // Post function func (c *Client) Post(b []byte, to string) (resp *http.Response, err error) { var req *http.Request - if req, err = c.NewRequest(b, to); err != nil { + if req, err = c.NewRequest(http.MethodPost, b, to); err != nil { return nil, err } resp, err = c.client.Do(req) return resp, err } + +// Create an http GET request with forgejo/gitea specific headers +func (c *Client) Get(to string) (resp *http.Response, err error) { // ToDo: we might not need the b parameter + var req *http.Request + emptyBody := []byte{0} + if req, err = c.NewRequest(http.MethodGet, emptyBody, to); err != nil { + return nil, err + } + resp, err = c.client.Do(req) + return resp, err +} + +// Create an http GET request with forgejo/gitea specific headers +func (c *Client) GetBody(uri string) ([]byte, error) { + response, err := c.Get(uri) + if err != nil { + return nil, err + } + log.Debug("Client: got status: %v", response.Status) + if response.StatusCode != 200 { + err = fmt.Errorf("got non 200 status code for id: %v", uri) + return nil, err + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + log.Debug("Client: got body: %v", charLimiter(string(body), 120)) + return body, nil +} + +// Limit number of characters in a string (useful to prevent log injection attacks and overly long log outputs) +// Thanks to https://www.socketloop.com/tutorials/golang-characters-limiter-example +func charLimiter(s string, limit int) string { + reader := strings.NewReader(s) + buff := make([]byte, limit) + n, _ := io.ReadAtLeast(reader, buff, limit) + if n != 0 { + return fmt.Sprint(string(buff), "...") + } + return s +} diff --git a/modules/activitypub/client_test.go b/modules/activitypub/client_test.go index 65ea8d4d5b..2ef16fcdf5 100644 --- a/modules/activitypub/client_test.go +++ b/modules/activitypub/client_test.go @@ -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}) diff --git a/modules/forgefed/actor.go b/modules/forgefed/actor.go new file mode 100644 index 0000000000..a34abc2075 --- /dev/null +++ b/modules/forgefed/actor.go @@ -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 +} diff --git a/modules/forgefed/actor_test.go b/modules/forgefed/actor_test.go new file mode 100644 index 0000000000..9a1dbd4c3d --- /dev/null +++ b/modules/forgefed/actor_test.go @@ -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()) + } +} diff --git a/modules/forgefed/nodeinfo.go b/modules/forgefed/nodeinfo.go new file mode 100644 index 0000000000..b22d2959d4 --- /dev/null +++ b/modules/forgefed/nodeinfo.go @@ -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 +} diff --git a/services/federation/federation_service.go b/services/federation/federation_service.go index 478b00df96..5aba8b38c5 100644 --- a/services/federation/federation_service.go +++ b/services/federation/federation_service.go @@ -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 +} From 778dd81615e1325a92f3d1bf06081718c9009605 Mon Sep 17 00:00:00 2001 From: Michael Jerger Date: Tue, 7 May 2024 18:40:27 +0200 Subject: [PATCH 2/6] unused code of the moment --- .deadcode-out | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/.deadcode-out b/.deadcode-out index f22a9df101..723b357918 100644 --- a/.deadcode-out +++ b/.deadcode-out @@ -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,9 +319,6 @@ package "code.gitea.io/gitea/modules/translation" package "code.gitea.io/gitea/modules/util/filebuffer" func CreateFromReader -package "code.gitea.io/gitea/modules/validation" - func ValidateMaxLen - package "code.gitea.io/gitea/modules/web" func RouteMock func RouteMockReset From 33648f2a4c86c45e38d69cd1cf3a65256ec1e5cd Mon Sep 17 00:00:00 2001 From: Michael Jerger Date: Fri, 10 May 2024 17:30:34 +0200 Subject: [PATCH 3/6] add second federated instance to integration test --- .../api_activitypub_repository_test.go | 46 +++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/tests/integration/api_activitypub_repository_test.go b/tests/integration/api_activitypub_repository_test.go index 19bf1cf094..e237ffeb1e 100644 --- a/tests/integration/api_activitypub_repository_test.go +++ b/tests/integration/api_activitypub_repository_test.go @@ -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,31 @@ 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("/", + 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 +109,26 @@ 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) + + federationHost := unittest.AssertExistsAndLoadBean(t, &forgefed.FederationHost{ID: 1}) + assert.Equal(t, "127.0.0.1", federationHost.HostFqdn) + }) } From fc38e5637365758cbee98b48416a3af5fedc93b1 Mon Sep 17 00:00:00 2001 From: Michael Jerger Date: Tue, 14 May 2024 08:24:31 +0200 Subject: [PATCH 4/6] enhance test & fix reviews --- .deadcode-out | 4 +++ models/forgefed/federationhost.go | 6 ++-- models/forgefed/federationhost_repository.go | 8 ++--- models/forgefed/federationhost_test.go | 25 +++++++++++++- models/forgefed/nodeinfo.go | 2 +- models/forgefed/nodeinfo_test.go | 7 ++-- models/forgejo_migrations/migrate.go | 2 ++ models/forgejo_migrations/v15.go | 33 +++++++++++++++++++ modules/activitypub/client.go | 2 +- modules/forgefed/actor.go | 8 ++--- modules/forgefed/actor_test.go | 4 ++- .../api_activitypub_repository_test.go | 20 +++++++++-- 12 files changed, 101 insertions(+), 20 deletions(-) create mode 100644 models/forgejo_migrations/v15.go diff --git a/.deadcode-out b/.deadcode-out index 723b357918..9845b885a1 100644 --- a/.deadcode-out +++ b/.deadcode-out @@ -131,6 +131,7 @@ package "code.gitea.io/gitea/models/user" func (ErrUserInactive).Unwrap func IsErrExternalLoginUserAlreadyExist func IsErrExternalLoginUserNotExist + func NewFederatedUser func IsErrUserSettingIsNotExist func GetUserAllSettings func DeleteUserSetting @@ -319,6 +320,9 @@ package "code.gitea.io/gitea/modules/translation" package "code.gitea.io/gitea/modules/util/filebuffer" func CreateFromReader +package "code.gitea.io/gitea/modules/validation" + func IsErrNotValid + package "code.gitea.io/gitea/modules/web" func RouteMock func RouteMockReset diff --git a/models/forgefed/federationhost.go b/models/forgefed/federationhost.go index eb1183a2a7..b60c0c39cf 100644 --- a/models/forgefed/federationhost.go +++ b/models/forgefed/federationhost.go @@ -19,11 +19,11 @@ type FederationHost struct { 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"` + Created timeutil.TimeStamp `xorm:"created"` Updated timeutil.TimeStamp `xorm:"updated"` } -// Factory function for PersonID. Created struct is asserted to be valid +// 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), @@ -45,7 +45,7 @@ func (host FederationHost) Validate() []string { 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)) + result = append(result, fmt.Sprintf("Latest Activity cannot be in the far future: %v", host.LatestActivity)) } return result diff --git a/models/forgefed/federationhost_repository.go b/models/forgefed/federationhost_repository.go index b4e72b0ce1..03d8741c58 100644 --- a/models/forgefed/federationhost_repository.go +++ b/models/forgefed/federationhost_repository.go @@ -25,7 +25,7 @@ func GetFederationHost(ctx context.Context, ID int64) (*FederationHost, error) { 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 nil, err } return host, nil } @@ -39,14 +39,14 @@ func FindFederationHostByFqdn(ctx context.Context, fqdn string) (*FederationHost return nil, nil } if res, err := validation.IsValid(host); !res { - return nil, fmt.Errorf("FederationInfo is not valid: %v", err) + return nil, 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) + return err } _, err := db.GetEngine(ctx).Insert(host) return err @@ -54,7 +54,7 @@ func CreateFederationHost(ctx context.Context, host *FederationHost) error { func UpdateFederationHost(ctx context.Context, host *FederationHost) error { if res, err := validation.IsValid(host); !res { - return fmt.Errorf("FederationInfo is not valid: %v", err) + return err } _, err := db.GetEngine(ctx).ID(host.ID).Update(host) return err diff --git a/models/forgefed/federationhost_test.go b/models/forgefed/federationhost_test.go index 04f941d93d..ea5494c6e9 100644 --- a/models/forgefed/federationhost_test.go +++ b/models/forgefed/federationhost_test.go @@ -4,6 +4,7 @@ package forgefed import ( + "strings" "testing" "time" @@ -22,13 +23,35 @@ func Test_FederationHostValidation(t *testing.T) { 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") + t.Errorf("sut should be invalid: NodeInfo invalid") } sut = FederationHost{ diff --git a/models/forgefed/nodeinfo.go b/models/forgefed/nodeinfo.go index bb56570635..66d2eca7aa 100644 --- a/models/forgefed/nodeinfo.go +++ b/models/forgefed/nodeinfo.go @@ -33,7 +33,7 @@ type NodeInfoWellKnown struct { Href string } -// Factory function for PersonID. Created struct is asserted to be valid +// Factory function for NodeInfoWellKnown. Created struct is asserted to be valid. func NewNodeInfoWellKnown(body []byte) (NodeInfoWellKnown, error) { result, err := NodeInfoWellKnownUnmarshalJSON(body) if err != nil { diff --git a/models/forgefed/nodeinfo_test.go b/models/forgefed/nodeinfo_test.go index ba1bd90be8..4c73bb44d8 100644 --- a/models/forgefed/nodeinfo_test.go +++ b/models/forgefed/nodeinfo_test.go @@ -6,6 +6,7 @@ package forgefed import ( "fmt" "reflect" + "strings" "testing" "code.gitea.io/gitea/modules/validation" @@ -52,12 +53,14 @@ func Test_NodeInfoWellKnownValidate(t *testing.T) { } 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]" { + _, 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"} - if _, err := validation.IsValid(sut); err.Error() != "Href may not contain query" { + _, 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) } } diff --git a/models/forgejo_migrations/migrate.go b/models/forgejo_migrations/migrate.go index 3b6da74149..fc5a460163 100644 --- a/models/forgejo_migrations/migrate.go +++ b/models/forgejo_migrations/migrate.go @@ -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. diff --git a/models/forgejo_migrations/v15.go b/models/forgejo_migrations/v15.go new file mode 100644 index 0000000000..d7ed19ca7c --- /dev/null +++ b/models/forgejo_migrations/v15.go @@ -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)) +} diff --git a/modules/activitypub/client.go b/modules/activitypub/client.go index 4962ad79a4..d47990430d 100644 --- a/modules/activitypub/client.go +++ b/modules/activitypub/client.go @@ -129,7 +129,7 @@ func (c *Client) Post(b []byte, to string) (resp *http.Response, err error) { } // 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 +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 { diff --git a/modules/forgefed/actor.go b/modules/forgefed/actor.go index a34abc2075..29d6f15d8b 100644 --- a/modules/forgefed/actor.go +++ b/modules/forgefed/actor.go @@ -32,8 +32,8 @@ func NewActorID(uri string) (ActorID, error) { return ActorID{}, err } - if valid, outcome := validation.IsValid(result); !valid { - return ActorID{}, outcome + if valid, err := validation.IsValid(result); !valid { + return ActorID{}, err } return result, nil @@ -83,8 +83,8 @@ func NewPersonID(uri, source string) (PersonID, error) { // validate Person specific path personID := PersonID{result} - if valid, outcome := validation.IsValid(personID); !valid { - return PersonID{}, outcome + if valid, err := validation.IsValid(personID); !valid { + return PersonID{}, err } return personID, nil diff --git a/modules/forgefed/actor_test.go b/modules/forgefed/actor_test.go index 9a1dbd4c3d..a3c01eceb0 100644 --- a/modules/forgefed/actor_test.go +++ b/modules/forgefed/actor_test.go @@ -92,7 +92,9 @@ func TestPersonIdValidation(t *testing.T) { 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" { + + _, 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) } diff --git a/tests/integration/api_activitypub_repository_test.go b/tests/integration/api_activitypub_repository_test.go index e237ffeb1e..67b18dac58 100644 --- a/tests/integration/api_activitypub_repository_test.go +++ b/tests/integration/api_activitypub_repository_test.go @@ -91,6 +91,22 @@ func TestActivityPubRepositoryInboxValid(t *testing.T) { `"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()) @@ -126,9 +142,7 @@ func TestActivityPubRepositoryInboxValid(t *testing.T) { assert.NoError(t, err) assert.Equal(t, http.StatusNoContent, resp.StatusCode) - federationHost := unittest.AssertExistsAndLoadBean(t, &forgefed.FederationHost{ID: 1}) - assert.Equal(t, "127.0.0.1", federationHost.HostFqdn) - + unittest.AssertExistsAndLoadBean(t, &forgefed.FederationHost{HostFqdn: "127.0.0.1"}) }) } From 9d32c5a29b31ce1c1234ea6d85f9ea6ac9873ab7 Mon Sep 17 00:00:00 2001 From: Michael Jerger Date: Tue, 14 May 2024 08:31:34 +0200 Subject: [PATCH 5/6] added validation fixes --- modules/validation/validatable.go | 19 ++++++++++++++++++- modules/validation/validatable_test.go | 6 +++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/modules/validation/validatable.go b/modules/validation/validatable.go index fc38ad2524..94b5cc135c 100644 --- a/modules/validation/validatable.go +++ b/modules/validation/validatable.go @@ -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 diff --git a/modules/validation/validatable_test.go b/modules/validation/validatable_test.go index fdc21f3223..919f5a3183 100644 --- a/modules/validation/validatable_test.go +++ b/modules/validation/validatable_test.go @@ -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) { From fddc063b978fcdf34ae8fe372c5a919169ed670d Mon Sep 17 00:00:00 2001 From: Michael Jerger Date: Tue, 14 May 2024 08:48:05 +0200 Subject: [PATCH 6/6] missed by diff --- .deadcode-out | 1 - modules/forgefed/actor.go | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.deadcode-out b/.deadcode-out index 9845b885a1..e7e6d217ab 100644 --- a/.deadcode-out +++ b/.deadcode-out @@ -131,7 +131,6 @@ package "code.gitea.io/gitea/models/user" func (ErrUserInactive).Unwrap func IsErrExternalLoginUserAlreadyExist func IsErrExternalLoginUserNotExist - func NewFederatedUser func IsErrUserSettingIsNotExist func GetUserAllSettings func DeleteUserSetting diff --git a/modules/forgefed/actor.go b/modules/forgefed/actor.go index 29d6f15d8b..d3cae20dec 100644 --- a/modules/forgefed/actor.go +++ b/modules/forgefed/actor.go @@ -137,8 +137,8 @@ func NewRepositoryID(uri, source string) (RepositoryID, error) { // validate Person specific path repoID := RepositoryID{result} - if valid, outcome := validation.IsValid(repoID); !valid { - return RepositoryID{}, outcome + if valid, err := validation.IsValid(repoID); !valid { + return RepositoryID{}, err } return repoID, nil