diff options
Diffstat (limited to 'modules/activitypub')
-rw-r--r-- | modules/activitypub/client.go | 273 | ||||
-rw-r--r-- | modules/activitypub/client_test.go | 138 | ||||
-rw-r--r-- | modules/activitypub/main_test.go | 18 | ||||
-rw-r--r-- | modules/activitypub/user_settings.go | 48 | ||||
-rw-r--r-- | modules/activitypub/user_settings_test.go | 30 |
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) +} |