summaryrefslogtreecommitdiffstats
path: root/modules/activitypub
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--modules/activitypub/client.go273
-rw-r--r--modules/activitypub/client_test.go138
-rw-r--r--modules/activitypub/main_test.go18
-rw-r--r--modules/activitypub/user_settings.go48
-rw-r--r--modules/activitypub/user_settings_test.go30
5 files changed, 507 insertions, 0 deletions
diff --git a/modules/activitypub/client.go b/modules/activitypub/client.go
new file mode 100644
index 0000000..064d898
--- /dev/null
+++ b/modules/activitypub/client.go
@@ -0,0 +1,273 @@
+// 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 (
+ "bytes"
+ "context"
+ "crypto/rsa"
+ "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"
+
+ "github.com/go-fed/httpsig"
+)
+
+const (
+ // ActivityStreamsContentType const
+ ActivityStreamsContentType = `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`
+ httpsigExpirationTime = 60
+)
+
+func CurrentTime() string {
+ return time.Now().UTC().Format(http.TimeFormat)
+}
+
+func containsRequiredHTTPHeaders(method string, headers []string) error {
+ var hasRequestTarget, hasDate, hasDigest, hasHost bool
+ for _, header := range headers {
+ hasRequestTarget = hasRequestTarget || header == httpsig.RequestTarget
+ hasDate = hasDate || header == "Date"
+ hasDigest = hasDigest || header == "Digest"
+ hasHost = hasHost || header == "Host"
+ }
+ 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 !hasHost {
+ return fmt.Errorf("missing http header for %s: Host", method)
+ } else if !hasDigest && method != http.MethodGet {
+ return fmt.Errorf("missing http header for %s: Digest", method)
+ }
+ return nil
+}
+
+// Client struct
+type ClientFactory struct {
+ client *http.Client
+ algs []httpsig.Algorithm
+ digestAlg httpsig.DigestAlgorithm
+ getHeaders []string
+ postHeaders []string
+}
+
+// NewClient function
+func NewClientFactory() (c *ClientFactory, err error) {
+ if err = containsRequiredHTTPHeaders(http.MethodGet, setting.Federation.GetHeaders); err != nil {
+ return nil, err
+ } else if err = containsRequiredHTTPHeaders(http.MethodPost, setting.Federation.PostHeaders); err != nil {
+ return nil, err
+ }
+
+ c = &ClientFactory{
+ client: &http.Client{
+ Transport: &http.Transport{
+ Proxy: proxy.Proxy(),
+ },
+ Timeout: 5 * time.Second,
+ },
+ algs: setting.HttpsigAlgs,
+ digestAlg: httpsig.DigestAlgorithm(setting.Federation.DigestAlgorithm),
+ getHeaders: setting.Federation.GetHeaders,
+ postHeaders: setting.Federation.PostHeaders,
+ }
+ return c, err
+}
+
+type APClientFactory interface {
+ WithKeys(ctx context.Context, user *user_model.User, pubID string) (APClient, error)
+}
+
+// Client struct
+type Client struct {
+ client *http.Client
+ algs []httpsig.Algorithm
+ digestAlg httpsig.DigestAlgorithm
+ getHeaders []string
+ postHeaders []string
+ priv *rsa.PrivateKey
+ pubID string
+}
+
+// NewRequest function
+func (cf *ClientFactory) WithKeys(ctx context.Context, user *user_model.User, pubID string) (APClient, error) {
+ priv, err := GetPrivateKey(ctx, user)
+ if err != nil {
+ return nil, err
+ }
+ privPem, _ := pem.Decode([]byte(priv))
+ privParsed, err := x509.ParsePKCS1PrivateKey(privPem.Bytes)
+ if err != nil {
+ return nil, err
+ }
+
+ c := Client{
+ client: cf.client,
+ algs: cf.algs,
+ digestAlg: cf.digestAlg,
+ getHeaders: cf.getHeaders,
+ postHeaders: cf.postHeaders,
+ priv: privParsed,
+ pubID: pubID,
+ }
+ return &c, nil
+}
+
+// NewRequest function
+func (c *Client) newRequest(method string, b []byte, to string) (req *http.Request, err error) {
+ buf := bytes.NewBuffer(b)
+ req, err = http.NewRequest(method, to, buf)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Add("Accept", "application/json, "+ActivityStreamsContentType)
+ req.Header.Add("Date", CurrentTime())
+ req.Header.Add("Host", req.URL.Host)
+ req.Header.Add("User-Agent", "Gitea/"+setting.AppVer)
+ req.Header.Add("Content-Type", ActivityStreamsContentType)
+
+ return req, err
+}
+
+// Post function
+func (c *Client) Post(b []byte, to string) (resp *http.Response, err error) {
+ var req *http.Request
+ if req, err = c.newRequest(http.MethodPost, b, to); err != nil {
+ return nil, err
+ }
+
+ signer, _, err := httpsig.NewSigner(c.algs, c.digestAlg, c.postHeaders, httpsig.Signature, httpsigExpirationTime)
+ if err != nil {
+ return nil, err
+ }
+ if err := signer.SignRequest(c.priv, c.pubID, req, b); err != nil {
+ return nil, err
+ }
+
+ resp, err = c.client.Do(req)
+ return resp, err
+}
+
+// Create an http GET request with forgejo/gitea specific headers
+func (c *Client) Get(to string) (resp *http.Response, err error) {
+ var req *http.Request
+ if req, err = c.newRequest(http.MethodGet, nil, to); err != nil {
+ return nil, err
+ }
+ signer, _, err := httpsig.NewSigner(c.algs, c.digestAlg, c.getHeaders, httpsig.Signature, httpsigExpirationTime)
+ if err != nil {
+ return nil, err
+ }
+ if err := signer.SignRequest(c.priv, c.pubID, req, nil); 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
+}
+
+type APClient interface {
+ newRequest(method string, b []byte, to string) (req *http.Request, err error)
+ Post(b []byte, to string) (resp *http.Response, err error)
+ Get(to string) (resp *http.Response, err error)
+ GetBody(uri string) ([]byte, error)
+}
+
+// contextKey is a value for use with context.WithValue.
+type contextKey struct {
+ name string
+}
+
+// clientFactoryContextKey is a context key. It is used with context.Value() to get the current Food for the context
+var (
+ clientFactoryContextKey = &contextKey{"clientFactory"}
+ _ APClientFactory = &ClientFactory{}
+)
+
+// Context represents an activitypub client factory context
+type Context struct {
+ context.Context
+ e APClientFactory
+}
+
+func NewContext(ctx context.Context, e APClientFactory) *Context {
+ return &Context{
+ Context: ctx,
+ e: e,
+ }
+}
+
+// APClientFactory represents an activitypub client factory
+func (ctx *Context) APClientFactory() APClientFactory {
+ return ctx.e
+}
+
+// provides APClientFactory
+type GetAPClient interface {
+ GetClientFactory() APClientFactory
+}
+
+// GetClientFactory will get an APClientFactory from this context or returns the default implementation
+func GetClientFactory(ctx context.Context) (APClientFactory, error) {
+ if e := getClientFactory(ctx); e != nil {
+ return e, nil
+ }
+ return NewClientFactory()
+}
+
+// getClientFactory will get an APClientFactory from this context or return nil
+func getClientFactory(ctx context.Context) APClientFactory {
+ if clientFactory, ok := ctx.(APClientFactory); ok {
+ return clientFactory
+ }
+ clientFactoryInterface := ctx.Value(clientFactoryContextKey)
+ if clientFactoryInterface != nil {
+ return clientFactoryInterface.(GetAPClient).GetClientFactory()
+ }
+ return nil
+}
diff --git a/modules/activitypub/client_test.go b/modules/activitypub/client_test.go
new file mode 100644
index 0000000..647a0a5
--- /dev/null
+++ b/modules/activitypub/client_test.go
@@ -0,0 +1,138 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package activitypub
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "regexp"
+ "testing"
+ "time"
+
+ "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/stretchr/testify/require"
+)
+
+func TestCurrentTime(t *testing.T) {
+ date := CurrentTime()
+ _, err := time.Parse(http.TimeFormat, date)
+ require.NoError(t, err)
+ assert.Equal(t, "GMT", date[len(date)-3:])
+}
+
+/* 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 TestClientCtx(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ pubID := "myGpgId"
+ cf, err := NewClientFactory()
+ log.Debug("ClientFactory: %v\nError: %v", cf, err)
+ require.NoError(t, err)
+
+ c, err := cf.WithKeys(db.DefaultContext, user, pubID)
+
+ log.Debug("Client: %v\nError: %v", c, err)
+ require.NoError(t, err)
+ _ = NewContext(db.DefaultContext, cf)
+}
+
+/* TODO: bring this test to work or delete
+func TestActivityPubSignedGet(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1, Name: "me"})
+ pubID := "myGpgId"
+ c, err := NewClient(db.DefaultContext, user, pubID)
+ require.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)
+ require.NoError(t, err)
+ assert.Equal(t, expected, string(body))
+ fmt.Fprint(w, expected)
+ }))
+ defer srv.Close()
+
+ r, err := c.Get(srv.URL)
+ require.NoError(t, err)
+ defer r.Body.Close()
+ body, err := io.ReadAll(r.Body)
+ require.NoError(t, err)
+ assert.Equal(t, expected, string(body))
+
+}
+*/
+
+func TestActivityPubSignedPost(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ pubID := "https://example.com/pubID"
+ cf, err := NewClientFactory()
+ require.NoError(t, err)
+ c, err := cf.WithKeys(db.DefaultContext, user, pubID)
+ require.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, ActivityStreamsContentType, r.Header.Get("Content-Type"))
+ body, err := io.ReadAll(r.Body)
+ require.NoError(t, err)
+ assert.Equal(t, expected, string(body))
+ fmt.Fprint(w, expected)
+ }))
+ defer srv.Close()
+
+ r, err := c.Post([]byte(expected), srv.URL)
+ require.NoError(t, err)
+ defer r.Body.Close()
+ body, err := io.ReadAll(r.Body)
+ require.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 0000000..4591f1f
--- /dev/null
+++ b/modules/activitypub/main_test.go
@@ -0,0 +1,18 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package activitypub
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+
+ _ "code.gitea.io/gitea/models"
+ _ "code.gitea.io/gitea/models/actions"
+ _ "code.gitea.io/gitea/models/activities"
+)
+
+func TestMain(m *testing.M) {
+ unittest.MainTest(m)
+}
diff --git a/modules/activitypub/user_settings.go b/modules/activitypub/user_settings.go
new file mode 100644
index 0000000..7f939af
--- /dev/null
+++ b/modules/activitypub/user_settings.go
@@ -0,0 +1,48 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package activitypub
+
+import (
+ "context"
+
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/util"
+)
+
+const rsaBits = 3072
+
+// GetKeyPair function returns a user's private and public keys
+func GetKeyPair(ctx context.Context, user *user_model.User) (pub, priv string, err error) {
+ var settings map[string]*user_model.Setting
+ settings, err = user_model.GetSettings(ctx, user.ID, []string{user_model.UserActivityPubPrivPem, user_model.UserActivityPubPubPem})
+ if err != nil {
+ return pub, priv, err
+ } else if len(settings) == 0 {
+ if priv, pub, err = util.GenerateKeyPair(rsaBits); err != nil {
+ return pub, priv, err
+ }
+ if err = user_model.SetUserSetting(ctx, user.ID, user_model.UserActivityPubPrivPem, priv); err != nil {
+ return pub, priv, err
+ }
+ if err = user_model.SetUserSetting(ctx, user.ID, user_model.UserActivityPubPubPem, pub); err != nil {
+ return pub, priv, err
+ }
+ return pub, priv, err
+ }
+ priv = settings[user_model.UserActivityPubPrivPem].SettingValue
+ pub = settings[user_model.UserActivityPubPubPem].SettingValue
+ return pub, priv, err
+}
+
+// GetPublicKey function returns a user's public key
+func GetPublicKey(ctx context.Context, user *user_model.User) (pub string, err error) {
+ pub, _, err = GetKeyPair(ctx, user)
+ return pub, err
+}
+
+// GetPrivateKey function returns a user's private key
+func GetPrivateKey(ctx context.Context, user *user_model.User) (priv string, err error) {
+ _, priv, err = GetKeyPair(ctx, user)
+ return priv, err
+}
diff --git a/modules/activitypub/user_settings_test.go b/modules/activitypub/user_settings_test.go
new file mode 100644
index 0000000..f510e7a
--- /dev/null
+++ b/modules/activitypub/user_settings_test.go
@@ -0,0 +1,30 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package activitypub
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+
+ _ "code.gitea.io/gitea/models" // https://forum.gitea.com/t/testfixtures-could-not-clean-table-access-no-such-table-access/4137/4
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestUserSettings(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+ user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ pub, priv, err := GetKeyPair(db.DefaultContext, user1)
+ require.NoError(t, err)
+ pub1, err := GetPublicKey(db.DefaultContext, user1)
+ require.NoError(t, err)
+ assert.Equal(t, pub, pub1)
+ priv1, err := GetPrivateKey(db.DefaultContext, user1)
+ require.NoError(t, err)
+ assert.Equal(t, priv, priv1)
+}