summaryrefslogtreecommitdiffstats
path: root/modules/activitypub
diff options
context:
space:
mode:
Diffstat (limited to 'modules/activitypub')
-rw-r--r--modules/activitypub/client.go124
-rw-r--r--modules/activitypub/client_test.go49
-rw-r--r--modules/activitypub/main_test.go18
-rw-r--r--modules/activitypub/user_settings.go45
-rw-r--r--modules/activitypub/user_settings_test.go29
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)
+}