diff options
Diffstat (limited to 'modules/activitypub')
-rw-r--r-- | modules/activitypub/client.go | 124 | ||||
-rw-r--r-- | modules/activitypub/client_test.go | 49 | ||||
-rw-r--r-- | modules/activitypub/main_test.go | 18 | ||||
-rw-r--r-- | modules/activitypub/user_settings.go | 45 | ||||
-rw-r--r-- | modules/activitypub/user_settings_test.go | 29 |
5 files changed, 265 insertions, 0 deletions
diff --git a/modules/activitypub/client.go b/modules/activitypub/client.go new file mode 100644 index 0000000000..738b1e4737 --- /dev/null +++ b/modules/activitypub/client.go @@ -0,0 +1,124 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package activitypub + +import ( + "bytes" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "net/http" + "strings" + "time" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/proxy" + "code.gitea.io/gitea/modules/setting" + + "github.com/go-fed/httpsig" +) + +const ( + // ActivityStreamsContentType const + ActivityStreamsContentType = `application/ld+json; profile="https://www.w3.org/ns/activitystreams"` + httpsigExpirationTime = 60 +) + +// Gets the current time as an RFC 2616 formatted string +// RFC 2616 requires RFC 1123 dates but with GMT instead of UTC +func CurrentTime() string { + return strings.ReplaceAll(time.Now().UTC().Format(time.RFC1123), "UTC", "GMT") +} + +func containsRequiredHTTPHeaders(method string, headers []string) error { + var hasRequestTarget, hasDate, hasDigest bool + for _, header := range headers { + hasRequestTarget = hasRequestTarget || header == httpsig.RequestTarget + hasDate = hasDate || header == "Date" + hasDigest = hasDigest || header == "Digest" + } + if !hasRequestTarget { + return fmt.Errorf("missing http header for %s: %s", method, httpsig.RequestTarget) + } else if !hasDate { + return fmt.Errorf("missing http header for %s: Date", method) + } else if !hasDigest && method != http.MethodGet { + return fmt.Errorf("missing http header for %s: Digest", method) + } + return nil +} + +// Client struct +type Client struct { + client *http.Client + algs []httpsig.Algorithm + digestAlg httpsig.DigestAlgorithm + getHeaders []string + postHeaders []string + priv *rsa.PrivateKey + pubID string +} + +// NewClient function +func NewClient(user *user_model.User, pubID string) (c *Client, err error) { + if err = containsRequiredHTTPHeaders(http.MethodGet, setting.Federation.GetHeaders); err != nil { + return + } else if err = containsRequiredHTTPHeaders(http.MethodPost, setting.Federation.PostHeaders); err != nil { + return + } + + priv, err := GetPrivateKey(user) + if err != nil { + return + } + privPem, _ := pem.Decode([]byte(priv)) + privParsed, err := x509.ParsePKCS1PrivateKey(privPem.Bytes) + if err != nil { + return + } + + c = &Client{ + client: &http.Client{ + Transport: &http.Transport{ + Proxy: proxy.Proxy(), + }, + }, + algs: setting.HttpsigAlgs, + digestAlg: httpsig.DigestAlgorithm(setting.Federation.DigestAlgorithm), + getHeaders: setting.Federation.GetHeaders, + postHeaders: setting.Federation.PostHeaders, + priv: privParsed, + pubID: pubID, + } + return +} + +// NewRequest function +func (c *Client) NewRequest(b []byte, to string) (req *http.Request, err error) { + buf := bytes.NewBuffer(b) + req, err = http.NewRequest(http.MethodPost, to, buf) + if err != nil { + return + } + req.Header.Add("Content-Type", ActivityStreamsContentType) + req.Header.Add("Date", CurrentTime()) + req.Header.Add("User-Agent", "Gitea/"+setting.AppVer) + signer, _, err := httpsig.NewSigner(c.algs, c.digestAlg, c.postHeaders, httpsig.Signature, httpsigExpirationTime) + if err != nil { + return + } + err = signer.SignRequest(c.priv, c.pubID, req, b) + return +} + +// 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 { + return + } + resp, err = c.client.Do(req) + return +} diff --git a/modules/activitypub/client_test.go b/modules/activitypub/client_test.go new file mode 100644 index 0000000000..b93ef5ac98 --- /dev/null +++ b/modules/activitypub/client_test.go @@ -0,0 +1,49 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package activitypub + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "regexp" + "testing" + + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + + _ "code.gitea.io/gitea/models" // https://discourse.gitea.io/t/testfixtures-could-not-clean-table-access-no-such-table-access/4137/4 + + "github.com/stretchr/testify/assert" +) + +func TestActivityPubSignedPost(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).(*user_model.User) + pubID := "https://example.com/pubID" + c, err := NewClient(user, pubID) + assert.NoError(t, err) + + expected := "BODY" + 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.Fprintf(w, expected) + })) + defer srv.Close() + + r, err := c.Post([]byte(expected), 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)) +} diff --git a/modules/activitypub/main_test.go b/modules/activitypub/main_test.go new file mode 100644 index 0000000000..7fa2b09265 --- /dev/null +++ b/modules/activitypub/main_test.go @@ -0,0 +1,18 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package activitypub + +import ( + "path/filepath" + "testing" + + "code.gitea.io/gitea/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m, &unittest.TestOptions{ + GiteaRootPath: filepath.Join("..", ".."), + }) +} diff --git a/modules/activitypub/user_settings.go b/modules/activitypub/user_settings.go new file mode 100644 index 0000000000..2144e7b47f --- /dev/null +++ b/modules/activitypub/user_settings.go @@ -0,0 +1,45 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package activitypub + +import ( + user_model "code.gitea.io/gitea/models/user" +) + +// GetKeyPair function returns a user's private and public keys +func GetKeyPair(user *user_model.User) (pub, priv string, err error) { + var settings map[string]*user_model.Setting + settings, err = user_model.GetUserSettings(user.ID, []string{user_model.UserActivityPubPrivPem, user_model.UserActivityPubPubPem}) + if err != nil { + return + } else if len(settings) == 0 { + if priv, pub, err = GenerateKeyPair(); err != nil { + return + } + if err = user_model.SetUserSetting(user.ID, user_model.UserActivityPubPrivPem, priv); err != nil { + return + } + if err = user_model.SetUserSetting(user.ID, user_model.UserActivityPubPubPem, pub); err != nil { + return + } + return + } else { + priv = settings[user_model.UserActivityPubPrivPem].SettingValue + pub = settings[user_model.UserActivityPubPubPem].SettingValue + return + } +} + +// GetPublicKey function returns a user's public key +func GetPublicKey(user *user_model.User) (pub string, err error) { + pub, _, err = GetKeyPair(user) + return +} + +// GetPrivateKey function returns a user's private key +func GetPrivateKey(user *user_model.User) (priv string, err error) { + _, priv, err = GetKeyPair(user) + return +} diff --git a/modules/activitypub/user_settings_test.go b/modules/activitypub/user_settings_test.go new file mode 100644 index 0000000000..90c6f680f9 --- /dev/null +++ b/modules/activitypub/user_settings_test.go @@ -0,0 +1,29 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package activitypub + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + _ "code.gitea.io/gitea/models" // https://discourse.gitea.io/t/testfixtures-could-not-clean-table-access-no-such-table-access/4137/4 + + "github.com/stretchr/testify/assert" +) + +func TestUserSettings(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).(*user_model.User) + pub, priv, err := GetKeyPair(user1) + assert.NoError(t, err) + pub1, err := GetPublicKey(user1) + assert.NoError(t, err) + assert.Equal(t, pub, pub1) + priv1, err := GetPrivateKey(user1) + assert.NoError(t, err) + assert.Equal(t, priv, priv1) +} |