aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--custom/conf/app.example.ini17
-rw-r--r--docs/content/doc/advanced/config-cheat-sheet.en-us.md8
-rw-r--r--go.mod7
-rw-r--r--go.sum14
-rw-r--r--integrations/api_activitypub_person_test.go103
-rw-r--r--integrations/webfinger_test.go2
-rw-r--r--models/user/setting_keys.go4
-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
-rw-r--r--modules/setting/federation.go30
-rw-r--r--modules/structs/activitypub.go10
-rw-r--r--routers/api/v1/activitypub/person.go106
-rw-r--r--routers/api/v1/activitypub/reqsignature.go102
-rw-r--r--routers/api/v1/api.go7
-rw-r--r--routers/api/v1/swagger/activitypub.go16
-rw-r--r--routers/web/webfinger.go7
-rw-r--r--templates/swagger/v1_json.tmpl69
20 files changed, 762 insertions, 5 deletions
diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini
index 8e5bfeac8e..c4b8913e41 100644
--- a/custom/conf/app.example.ini
+++ b/custom/conf/app.example.ini
@@ -2253,6 +2253,23 @@ PATH =
;;
;; Enable/Disable user statistics for nodeinfo if federation is enabled
; SHARE_USER_STATISTICS = true
+;;
+;; Maximum federation request and response size (MB)
+; MAX_SIZE = 4
+;;
+;; WARNING: Changing the settings below can break federation.
+;;
+;; HTTP signature algorithms
+; ALGORITHMS = rsa-sha256, rsa-sha512, ed25519
+;;
+;; HTTP signature digest algorithm
+; DIGEST_ALGORITHM = SHA-256
+;;
+;; GET headers for federation requests
+; GET_HEADERS = (request-target), Date
+;;
+;; POST headers for federation requests
+; POST_HEADERS = (request-target), Date, Digest
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
diff --git a/docs/content/doc/advanced/config-cheat-sheet.en-us.md b/docs/content/doc/advanced/config-cheat-sheet.en-us.md
index bce45d4da0..b50e630a83 100644
--- a/docs/content/doc/advanced/config-cheat-sheet.en-us.md
+++ b/docs/content/doc/advanced/config-cheat-sheet.en-us.md
@@ -1090,6 +1090,14 @@ Task queue configuration has been moved to `queue.task`. However, the below conf
- `ENABLED`: **true**: Enable/Disable federation capabilities
- `SHARE_USER_STATISTICS`: **true**: Enable/Disable user statistics for nodeinfo if federation is enabled
+- `MAX_SIZE`: **4**: Maximum federation request and response size (MB)
+
+ WARNING: Changing the settings below can break federation.
+
+- `ALGORITHMS`: **rsa-sha256, rsa-sha512, ed25519**: HTTP signature algorithms
+- `DIGEST_ALGORITHM`: **SHA-256**: HTTP signature digest algorithm
+- `GET_HEADERS`: **(request-target), Date**: GET headers for federation requests
+- `POST_HEADERS`: **(request-target), Date, Digest**: POST headers for federation requests
## Packages (`packages`)
diff --git a/go.mod b/go.mod
index e06ccfe13d..e9b4194c79 100644
--- a/go.mod
+++ b/go.mod
@@ -28,10 +28,12 @@ require (
github.com/ethantkoenig/rupture v1.0.1
github.com/felixge/fgprof v0.9.2
github.com/gliderlabs/ssh v0.3.4
+ github.com/go-ap/activitypub v0.0.0-20220615144428-48208c70483b
+ github.com/go-ap/jsonld v0.0.0-20220615144122-1d862b15410d
github.com/go-chi/chi/v5 v5.0.7
github.com/go-chi/cors v1.2.1
github.com/go-enry/go-enry/v2 v2.8.2
- github.com/go-fed/httpsig v1.1.0
+ github.com/go-fed/httpsig v1.1.1-0.20201223112313-55836744818e
github.com/go-git/go-billy/v5 v5.3.1
github.com/go-git/go-git/v5 v5.4.3-0.20210630082519-b4368b2a2ca4
github.com/go-ldap/ldap/v3 v3.4.3
@@ -107,6 +109,7 @@ require (
require (
cloud.google.com/go v0.99.0 // indirect
+ git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20200411073322-f0bcc40f0bf2 // indirect
github.com/Azure/go-ntlmssp v0.0.0-20211209120228-48547f28849e // indirect
github.com/Microsoft/go-winio v0.5.2 // indirect
github.com/ProtonMail/go-crypto v0.0.0-20220407094043-a94812496cf5 // indirect
@@ -160,6 +163,7 @@ require (
github.com/fsnotify/fsnotify v1.5.4 // indirect
github.com/fullstorydev/grpcurl v1.8.1 // indirect
github.com/fxamacker/cbor/v2 v2.4.0 // indirect
+ github.com/go-ap/errors v0.0.0-20220615144307-e8bc4a40ae9f // indirect
github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect
github.com/go-enry/go-oniguruma v1.2.1 // indirect
github.com/go-git/gcfg v1.5.0 // indirect
@@ -252,6 +256,7 @@ require (
github.com/toqueteos/webbrowser v1.2.0 // indirect
github.com/ulikunitz/xz v0.5.10 // indirect
github.com/unknwon/com v1.0.1 // indirect
+ github.com/valyala/fastjson v1.6.3 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xanzy/ssh-agent v0.3.1 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
diff --git a/go.sum b/go.sum
index 9c99adfbe1..ae4d06d1f9 100644
--- a/go.sum
+++ b/go.sum
@@ -69,6 +69,8 @@ contrib.go.opencensus.io/exporter/stackdriver v0.13.5/go.mod h1:aXENhDJ1Y4lIg4EU
contrib.go.opencensus.io/integrations/ocsql v0.1.4/go.mod h1:8DsSdjz3F+APR+0z0WkU1aRorQCFfRxvqjUUPMbF3fE=
contrib.go.opencensus.io/resource v0.1.1/go.mod h1:F361eGI91LCmW1I/Saf+rX0+OFcigGlFvXwEGEnkRLA=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
+git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20200411073322-f0bcc40f0bf2 h1:2OrsyJYZp7J6nyAsKi2q1SELYRaIc0aQmcQ/EQqPfk8=
+git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20200411073322-f0bcc40f0bf2/go.mod h1:g/V2Hjas6Z1UHUp4yIx6bATpNzJ7DYtD0FG3+xARWxs=
gitea.com/go-chi/binding v0.0.0-20220309004920-114340dabecb h1:Yy0Bxzc8R2wxiwXoG/rECGplJUSpXqCsog9PuJFgiHs=
gitea.com/go-chi/binding v0.0.0-20220309004920-114340dabecb/go.mod h1:77TZu701zMXWJFvB8gvTbQ92zQ3DQq/H7l5wAEjQRKc=
gitea.com/go-chi/cache v0.0.0-20210110083709-82c4c9ce2d5e/go.mod h1:k2V/gPDEtXGjjMGuBJiapffAXTv76H4snSmlJRLUhH0=
@@ -460,6 +462,12 @@ github.com/globalsign/mgo v0.0.0-20180905125535-1ca0a4f7cbcb/go.mod h1:xkRDCp4j0
github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q=
github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
+github.com/go-ap/activitypub v0.0.0-20220615144428-48208c70483b h1:+RjYfEfoZdM3wHFs752dlOpGaoRhwRRyQxjajg08LcQ=
+github.com/go-ap/activitypub v0.0.0-20220615144428-48208c70483b/go.mod h1:DE3vvc6Didgfd3k7M1Mos6qMDFNmMrxJmYVMHG9h9Io=
+github.com/go-ap/errors v0.0.0-20220615144307-e8bc4a40ae9f h1:kJhGo4NApJP0Lt9lkJnfmuTnRWVFbCynY0kiTxpPUR4=
+github.com/go-ap/errors v0.0.0-20220615144307-e8bc4a40ae9f/go.mod h1:KHkKFKZvc05lr79+RGoq/zG8YjWi3+FK60Bxd+mpCew=
+github.com/go-ap/jsonld v0.0.0-20220615144122-1d862b15410d h1:Z/oRXMlZHjvjIqDma1FrIGL3iE5YL7MUI0bwYEZ6qbA=
+github.com/go-ap/jsonld v0.0.0-20220615144122-1d862b15410d/go.mod h1:jyveZeGw5LaADntW+UEsMjl3IlIwk+DxlYNsbofQkGA=
github.com/go-asn1-ber/asn1-ber v1.5.4 h1:vXT6d/FNDiELJnLb6hGNa309LMsrCoYFvpwHDF0+Y1A=
github.com/go-asn1-ber/asn1-ber v1.5.4/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-chi/chi/v5 v5.0.1/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
@@ -472,8 +480,8 @@ github.com/go-enry/go-enry/v2 v2.8.2 h1:uiGmC+3K8sVd/6DOe2AOJEOihJdqda83nPyJNtMR
github.com/go-enry/go-enry/v2 v2.8.2/go.mod h1:GVzIiAytiS5uT/QiuakK7TF1u4xDab87Y8V5EJRpsIQ=
github.com/go-enry/go-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo=
github.com/go-enry/go-oniguruma v1.2.1/go.mod h1:bWDhYP+S6xZQgiRL7wlTScFYBe023B6ilRZbCAD5Hf4=
-github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
-github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
+github.com/go-fed/httpsig v1.1.1-0.20201223112313-55836744818e h1:oRq/fiirun5HqlEWMLIcDmLpIELlG4iGbd0s8iqgPi8=
+github.com/go-fed/httpsig v1.1.1-0.20201223112313-55836744818e/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4=
github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E=
github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
@@ -1507,6 +1515,8 @@ github.com/urfave/cli v1.22.9 h1:cv3/KhXGBGjEXLC4bH0sLuJ9BewaAbpk5oyMOveu4pw=
github.com/urfave/cli v1.22.9/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/urfave/cli/v2 v2.2.0/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
+github.com/valyala/fastjson v1.6.3 h1:tAKFnnwmeMGPbwJ7IwxcTPCNr3uIzoIj3/Fh90ra4xc=
+github.com/valyala/fastjson v1.6.3/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
github.com/vektah/gqlparser v1.1.2/go.mod h1:1ycwN7Ij5njmMkPPAOaRFY4rET2Enx7IkVv3vaXspKw=
github.com/weppos/publicsuffix-go v0.13.1-0.20210123135404-5fd73613514e/go.mod h1:HYux0V0Zi04bHNwOHy4cXJVz/TQjYonnF6aoYhj+3QE=
diff --git a/integrations/api_activitypub_person_test.go b/integrations/api_activitypub_person_test.go
new file mode 100644
index 0000000000..4898d5e01d
--- /dev/null
+++ b/integrations/api_activitypub_person_test.go
@@ -0,0 +1,103 @@
+// 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 integrations
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/activitypub"
+ "code.gitea.io/gitea/modules/setting"
+
+ ap "github.com/go-ap/activitypub"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestActivityPubPerson(t *testing.T) {
+ onGiteaRun(t, func(*testing.T, *url.URL) {
+ setting.Federation.Enabled = true
+ defer func() {
+ setting.Federation.Enabled = false
+ }()
+
+ username := "user2"
+ req := NewRequestf(t, "GET", fmt.Sprintf("/api/v1/activitypub/user/%s", username))
+ resp := MakeRequest(t, req, http.StatusOK)
+ body := resp.Body.Bytes()
+ assert.Contains(t, string(body), "@context")
+
+ var person ap.Person
+ err := person.UnmarshalJSON(body)
+ assert.NoError(t, err)
+
+ assert.Equal(t, ap.PersonType, person.Type)
+ assert.Equal(t, username, person.PreferredUsername.String())
+ keyID := person.GetID().String()
+ assert.Regexp(t, fmt.Sprintf("activitypub/user/%s$", username), keyID)
+ assert.Regexp(t, fmt.Sprintf("activitypub/user/%s/outbox$", username), person.Outbox.GetID().String())
+ assert.Regexp(t, fmt.Sprintf("activitypub/user/%s/inbox$", username), person.Inbox.GetID().String())
+
+ pubKey := person.PublicKey
+ assert.NotNil(t, pubKey)
+ publicKeyID := keyID + "#main-key"
+ assert.Equal(t, pubKey.ID.String(), publicKeyID)
+
+ pubKeyPem := pubKey.PublicKeyPem
+ assert.NotNil(t, pubKeyPem)
+ assert.Regexp(t, "^-----BEGIN PUBLIC KEY-----", pubKeyPem)
+ })
+}
+
+func TestActivityPubMissingPerson(t *testing.T) {
+ onGiteaRun(t, func(*testing.T, *url.URL) {
+ setting.Federation.Enabled = true
+ defer func() {
+ setting.Federation.Enabled = false
+ }()
+
+ req := NewRequestf(t, "GET", "/api/v1/activitypub/user/nonexistentuser")
+ resp := MakeRequest(t, req, http.StatusNotFound)
+ assert.Contains(t, resp.Body.String(), "user redirect does not exist")
+ })
+}
+
+func TestActivityPubPersonInbox(t *testing.T) {
+ srv := httptest.NewServer(c)
+ defer srv.Close()
+
+ onGiteaRun(t, func(*testing.T, *url.URL) {
+ appURL := setting.AppURL
+ setting.Federation.Enabled = true
+ setting.AppURL = srv.URL
+ defer func() {
+ setting.Federation.Enabled = false
+ setting.Database.LogSQL = false
+ setting.AppURL = appURL
+ }()
+ username1 := "user1"
+ ctx := context.Background()
+ user1, err := user_model.GetUserByName(ctx, username1)
+ assert.NoError(t, err)
+ user1url := fmt.Sprintf("%s/api/v1/activitypub/user/%s#main-key", srv.URL, username1)
+ c, err := activitypub.NewClient(user1, user1url)
+ assert.NoError(t, err)
+ username2 := "user2"
+ user2inboxurl := fmt.Sprintf("%s/api/v1/activitypub/user/%s/inbox", srv.URL, username2)
+
+ // Signed request succeeds
+ resp, err := c.Post([]byte{}, user2inboxurl)
+ assert.NoError(t, err)
+ assert.Equal(t, http.StatusNoContent, resp.StatusCode)
+
+ // Unsigned request fails
+ req := NewRequest(t, "POST", user2inboxurl)
+ MakeRequest(t, req, http.StatusInternalServerError)
+ })
+}
diff --git a/integrations/webfinger_test.go b/integrations/webfinger_test.go
index 8ba93c3f20..07bf58b509 100644
--- a/integrations/webfinger_test.go
+++ b/integrations/webfinger_test.go
@@ -52,7 +52,7 @@ func TestWebfinger(t *testing.T) {
var jrd webfingerJRD
DecodeJSON(t, resp, &jrd)
assert.Equal(t, "acct:user2@"+appURL.Host, jrd.Subject)
- assert.ElementsMatch(t, []string{user.HTMLURL()}, jrd.Aliases)
+ assert.ElementsMatch(t, []string{user.HTMLURL(), appURL.String() + "api/v1/activitypub/user/" + url.PathEscape(user.Name)}, jrd.Aliases)
req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=acct:%s@%s", user.LowerName, "unknown.host"))
MakeRequest(t, req, http.StatusBadRequest)
diff --git a/models/user/setting_keys.go b/models/user/setting_keys.go
index 109b5dd916..d48ac93052 100644
--- a/models/user/setting_keys.go
+++ b/models/user/setting_keys.go
@@ -9,4 +9,8 @@ const (
SettingsKeyHiddenCommentTypes = "issue.hidden_comment_types"
// SettingsKeyDiffWhitespaceBehavior is the setting key for whitespace behavior of diff
SettingsKeyDiffWhitespaceBehavior = "diff.whitespace_behaviour"
+ // UserActivityPubPrivPem is user's private key
+ UserActivityPubPrivPem = "activitypub.priv_pem"
+ // UserActivityPubPubPem is user's public key
+ UserActivityPubPubPem = "activitypub.pub_pem"
)
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)
+}
diff --git a/modules/setting/federation.go b/modules/setting/federation.go
index fd39e5c7c2..b06d0a9219 100644
--- a/modules/setting/federation.go
+++ b/modules/setting/federation.go
@@ -4,21 +4,49 @@
package setting
-import "code.gitea.io/gitea/modules/log"
+import (
+ "code.gitea.io/gitea/modules/log"
+
+ "github.com/go-fed/httpsig"
+)
// Federation settings
var (
Federation = struct {
Enabled bool
ShareUserStatistics bool
+ MaxSize int64
+ Algorithms []string
+ DigestAlgorithm string
+ GetHeaders []string
+ PostHeaders []string
}{
Enabled: true,
ShareUserStatistics: true,
+ MaxSize: 4,
+ Algorithms: []string{"rsa-sha256", "rsa-sha512", "ed25519"},
+ DigestAlgorithm: "SHA-256",
+ GetHeaders: []string{"(request-target)", "Date"},
+ PostHeaders: []string{"(request-target)", "Date", "Digest"},
}
)
+// Constant slice of httpsig algorithm objects
+var HttpsigAlgs []httpsig.Algorithm
+
func newFederationService() {
if err := Cfg.Section("federation").MapTo(&Federation); err != nil {
log.Fatal("Failed to map Federation settings: %v", err)
+ } else if !httpsig.IsSupportedDigestAlgorithm(Federation.DigestAlgorithm) {
+ log.Fatal("unsupported digest algorithm: %s", Federation.DigestAlgorithm)
+ return
+ }
+
+ // Get MaxSize in bytes instead of MiB
+ Federation.MaxSize = 1 << 20 * Federation.MaxSize
+
+ HttpsigAlgs = make([]httpsig.Algorithm, len(Federation.Algorithms))
+ for i, alg := range Federation.Algorithms {
+ HttpsigAlgs[i] = httpsig.Algorithm(alg)
}
}
diff --git a/modules/structs/activitypub.go b/modules/structs/activitypub.go
new file mode 100644
index 0000000000..86681bf9d7
--- /dev/null
+++ b/modules/structs/activitypub.go
@@ -0,0 +1,10 @@
+// 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 structs
+
+// ActivityPub type
+type ActivityPub struct {
+ Context string `json:"@context"`
+}
diff --git a/routers/api/v1/activitypub/person.go b/routers/api/v1/activitypub/person.go
new file mode 100644
index 0000000000..7290f1cbd9
--- /dev/null
+++ b/routers/api/v1/activitypub/person.go
@@ -0,0 +1,106 @@
+// 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 (
+ "net/http"
+ "strings"
+
+ "code.gitea.io/gitea/modules/activitypub"
+ "code.gitea.io/gitea/modules/context"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+
+ ap "github.com/go-ap/activitypub"
+ "github.com/go-ap/jsonld"
+)
+
+// Person function returns the Person actor for a user
+func Person(ctx *context.APIContext) {
+ // swagger:operation GET /activitypub/user/{username} activitypub activitypubPerson
+ // ---
+ // summary: Returns the Person actor for a user
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: username
+ // in: path
+ // description: username of the user
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/ActivityPub"
+
+ link := strings.TrimSuffix(setting.AppURL, "/") + "/api/v1/activitypub/user/" + ctx.ContextUser.Name
+ person := ap.PersonNew(ap.IRI(link))
+
+ person.Name = ap.NaturalLanguageValuesNew()
+ err := person.Name.Set("en", ap.Content(ctx.ContextUser.FullName))
+ if err != nil {
+ ctx.ServerError("Set Name", err)
+ return
+ }
+
+ person.PreferredUsername = ap.NaturalLanguageValuesNew()
+ err = person.PreferredUsername.Set("en", ap.Content(ctx.ContextUser.Name))
+ if err != nil {
+ ctx.ServerError("Set PreferredUsername", err)
+ return
+ }
+
+ person.URL = ap.IRI(ctx.ContextUser.HTMLURL())
+
+ person.Icon = ap.Image{
+ Type: ap.ImageType,
+ MediaType: "image/png",
+ URL: ap.IRI(ctx.ContextUser.AvatarLink()),
+ }
+
+ person.Inbox = ap.IRI(link + "/inbox")
+ person.Outbox = ap.IRI(link + "/outbox")
+
+ person.PublicKey.ID = ap.IRI(link + "#main-key")
+ person.PublicKey.Owner = ap.IRI(link)
+
+ publicKeyPem, err := activitypub.GetPublicKey(ctx.ContextUser)
+ if err != nil {
+ ctx.ServerError("GetPublicKey", err)
+ return
+ }
+ person.PublicKey.PublicKeyPem = publicKeyPem
+
+ binary, err := jsonld.WithContext(jsonld.IRI(ap.ActivityBaseURI), jsonld.IRI(ap.SecurityContextURI)).Marshal(person)
+ if err != nil {
+ ctx.ServerError("MarshalJSON", err)
+ return
+ }
+ ctx.Resp.Header().Add("Content-Type", activitypub.ActivityStreamsContentType)
+ ctx.Resp.WriteHeader(http.StatusOK)
+ if _, err = ctx.Resp.Write(binary); err != nil {
+ log.Error("write to resp err: %v", err)
+ }
+}
+
+// PersonInbox function handles the incoming data for a user inbox
+func PersonInbox(ctx *context.APIContext) {
+ // swagger:operation POST /activitypub/user/{username}/inbox activitypub activitypubPersonInbox
+ // ---
+ // summary: Send to the inbox
+ // produces:
+ // - application/json
+ // parameters:
+ // - name: username
+ // in: path
+ // description: username of the user
+ // type: string
+ // required: true
+ // responses:
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+
+ ctx.Status(http.StatusNoContent)
+}
diff --git a/routers/api/v1/activitypub/reqsignature.go b/routers/api/v1/activitypub/reqsignature.go
new file mode 100644
index 0000000000..b870d1c0f9
--- /dev/null
+++ b/routers/api/v1/activitypub/reqsignature.go
@@ -0,0 +1,102 @@
+// 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 (
+ "crypto"
+ "crypto/x509"
+ "encoding/pem"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+
+ "code.gitea.io/gitea/modules/activitypub"
+ gitea_context "code.gitea.io/gitea/modules/context"
+ "code.gitea.io/gitea/modules/httplib"
+ "code.gitea.io/gitea/modules/setting"
+
+ ap "github.com/go-ap/activitypub"
+ "github.com/go-fed/httpsig"
+)
+
+func getPublicKeyFromResponse(b []byte, keyID *url.URL) (p crypto.PublicKey, err error) {
+ person := ap.PersonNew(ap.IRI(keyID.String()))
+ err = person.UnmarshalJSON(b)
+ if err != nil {
+ err = fmt.Errorf("ActivityStreams type cannot be converted to one known to have publicKey property: %v", err)
+ return
+ }
+ pubKey := person.PublicKey
+ if pubKey.ID.String() != keyID.String() {
+ err = fmt.Errorf("cannot find publicKey with id: %s in %s", keyID, string(b))
+ return
+ }
+ pubKeyPem := pubKey.PublicKeyPem
+ block, _ := pem.Decode([]byte(pubKeyPem))
+ if block == nil || block.Type != "PUBLIC KEY" {
+ err = fmt.Errorf("could not decode publicKeyPem to PUBLIC KEY pem block type")
+ return
+ }
+ p, err = x509.ParsePKIXPublicKey(block.Bytes)
+ return
+}
+
+func fetch(iri *url.URL) (b []byte, err error) {
+ req := httplib.NewRequest(iri.String(), http.MethodGet)
+ req.Header("Accept", activitypub.ActivityStreamsContentType)
+ req.Header("User-Agent", "Gitea/"+setting.AppVer)
+ resp, err := req.Response()
+ if err != nil {
+ return
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ err = fmt.Errorf("url IRI fetch [%s] failed with status (%d): %s", iri, resp.StatusCode, resp.Status)
+ return
+ }
+ b, err = io.ReadAll(io.LimitReader(resp.Body, setting.Federation.MaxSize))
+ return
+}
+
+func verifyHTTPSignatures(ctx *gitea_context.APIContext) (authenticated bool, err error) {
+ r := ctx.Req
+
+ // 1. Figure out what key we need to verify
+ v, err := httpsig.NewVerifier(r)
+ if err != nil {
+ return
+ }
+ ID := v.KeyId()
+ idIRI, err := url.Parse(ID)
+ if err != nil {
+ return
+ }
+ // 2. Fetch the public key of the other actor
+ b, err := fetch(idIRI)
+ if err != nil {
+ return
+ }
+ pubKey, err := getPublicKeyFromResponse(b, idIRI)
+ if err != nil {
+ return
+ }
+ // 3. Verify the other actor's key
+ algo := httpsig.Algorithm(setting.Federation.Algorithms[0])
+ authenticated = v.Verify(pubKey, algo) == nil
+ return
+}
+
+// ReqHTTPSignature function
+func ReqHTTPSignature() func(ctx *gitea_context.APIContext) {
+ return func(ctx *gitea_context.APIContext) {
+ if authenticated, err := verifyHTTPSignatures(ctx); err != nil {
+ ctx.ServerError("verifyHttpSignatures", err)
+ } else if !authenticated {
+ ctx.Error(http.StatusForbidden, "reqSignature", "request signature verification failed")
+ }
+ }
+}
diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index 03f7a57d5c..c93606ae88 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -81,6 +81,7 @@ import (
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/routers/api/v1/activitypub"
"code.gitea.io/gitea/routers/api/v1/admin"
"code.gitea.io/gitea/routers/api/v1/misc"
"code.gitea.io/gitea/routers/api/v1/notify"
@@ -643,6 +644,12 @@ func Routes() *web.Route {
m.Get("/version", misc.Version)
if setting.Federation.Enabled {
m.Get("/nodeinfo", misc.NodeInfo)
+ m.Group("/activitypub", func() {
+ m.Group("/user/{username}", func() {
+ m.Get("", activitypub.Person)
+ m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox)
+ }, context_service.UserAssignmentAPI())
+ })
}
m.Get("/signing-key.gpg", misc.SigningKey)
m.Post("/markdown", bind(api.MarkdownOption{}), misc.Markdown)
diff --git a/routers/api/v1/swagger/activitypub.go b/routers/api/v1/swagger/activitypub.go
new file mode 100644
index 0000000000..afc0c05057
--- /dev/null
+++ b/routers/api/v1/swagger/activitypub.go
@@ -0,0 +1,16 @@
+// 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 swagger
+
+import (
+ api "code.gitea.io/gitea/modules/structs"
+)
+
+// ActivityPub
+// swagger:response ActivityPub
+type swaggerResponseActivityPub struct {
+ // in:body
+ Body api.ActivityPub `json:"body"`
+}
diff --git a/routers/web/webfinger.go b/routers/web/webfinger.go
index 8402967867..c4808fbfd2 100644
--- a/routers/web/webfinger.go
+++ b/routers/web/webfinger.go
@@ -86,6 +86,7 @@ func WebfingerQuery(ctx *context.Context) {
aliases := []string{
u.HTMLURL(),
+ appURL.String() + "api/v1/activitypub/user/" + url.PathEscape(u.Name),
}
if !u.KeepEmailPrivate {
aliases = append(aliases, fmt.Sprintf("mailto:%s", u.Email))
@@ -101,8 +102,14 @@ func WebfingerQuery(ctx *context.Context) {
Rel: "http://webfinger.net/rel/avatar",
Href: u.AvatarLink(),
},
+ {
+ Rel: "self",
+ Type: "application/activity+json",
+ Href: appURL.String() + "api/v1/activitypub/user/" + url.PathEscape(u.Name),
+ },
}
+ ctx.Resp.Header().Add("Access-Control-Allow-Origin", "*")
ctx.JSON(http.StatusOK, &webfingerJRD{
Subject: fmt.Sprintf("acct:%s@%s", url.QueryEscape(u.Name), appURL.Host),
Aliases: aliases,
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 4da8b12af4..f3f9a33672 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -23,6 +23,58 @@
},
"basePath": "{{AppSubUrl | JSEscape | Safe}}/api/v1",
"paths": {
+ "/activitypub/user/{username}": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "activitypub"
+ ],
+ "summary": "Returns the Person actor for a user",
+ "operationId": "activitypubPerson",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "username of the user",
+ "name": "username",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "$ref": "#/responses/ActivityPub"
+ }
+ }
+ }
+ },
+ "/activitypub/user/{username}/inbox": {
+ "post": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "activitypub"
+ ],
+ "summary": "Send to the inbox",
+ "operationId": "activitypubPersonInbox",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "username of the user",
+ "name": "username",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "204": {
+ "$ref": "#/responses/empty"
+ }
+ }
+ }
+ },
"/admin/cron": {
"get": {
"produces": [
@@ -13113,6 +13165,17 @@
},
"x-go-package": "code.gitea.io/gitea/modules/structs"
},
+ "ActivityPub": {
+ "description": "ActivityPub type",
+ "type": "object",
+ "properties": {
+ "@context": {
+ "type": "string",
+ "x-go-name": "Context"
+ }
+ },
+ "x-go-package": "code.gitea.io/gitea/modules/structs"
+ },
"AddCollaboratorOption": {
"description": "AddCollaboratorOption options when adding a user as a collaborator of a repository",
"type": "object",
@@ -18794,6 +18857,12 @@
}
}
},
+ "ActivityPub": {
+ "description": "ActivityPub",
+ "schema": {
+ "$ref": "#/definitions/ActivityPub"
+ }
+ },
"AnnotatedTag": {
"description": "AnnotatedTag",
"schema": {