Compare commits

...
This repository has been archived on 2024-01-11. You can view files and clone it, but cannot push or open issues or pull requests.

31 commits

Author SHA1 Message Date
d602177eed
PersonInboxGet and PersonOutboxPost 2022-04-05 11:18:04 -05:00
530175e030
Implement pub.database InboxForActor function 2022-04-05 10:58:07 -05:00
49b8518b11
Finish implementing everything else except for a few db methods 2022-04-05 10:51:38 -05:00
fccc924403
Finish pub.database implementation 2022-04-05 10:30:52 -05:00
325448fadf
Implement more pub.database functions 2022-04-04 22:51:03 -05:00
02f28f01f0
Add latest code 2022-04-04 22:06:43 -05:00
4307cb9207
Inbox/outbox prototype using go-fed pub 2022-03-31 10:15:25 -05:00
c9517a2a3a
Add API path for outbox 2022-03-31 09:02:56 -05:00
7ea5e108a5
Use system timezone instead of setting.DefaultUILocation 2022-03-30 17:51:44 -05:00
fdae736f22
Remove unneeded copy 2022-03-30 16:29:47 -05:00
1da0d49de7
Clean up some variable declarations 2022-03-30 15:52:52 -05:00
65016b2664
Check if digest algo is supported in setting module 2022-03-29 16:38:27 -05:00
d1a53f7d6a
Use time.RFC1123 and make the http.Client proxy-aware 2022-03-28 12:37:52 -05:00
373a84a8e2
Clean up whitespace with make fmt 2022-03-27 12:25:56 -05:00
21c56f8e94
Use the httplib module instead of http for GET requests 2022-03-27 12:25:27 -05:00
3ed4a71a4c
Rename hack_16834 to user_settings
Signed-off-by: Anthony Wang <ta180m@pm.me>
2022-03-25 11:53:16 -05:00
46973f99fa
Cleanup, handle invalid usernames for ActivityPub person GET request
Signed-off-by: Anthony Wang <ta180m@pm.me>
2022-03-24 18:44:44 -05:00
ebef769703
Assert if json.Unmarshal succeeds
Signed-off-by: Anthony Wang <ta180m@pm.me>
2022-03-20 10:53:05 -05:00
d75809aeee
Remove LogSQL from ActivityPub person test
Signed-off-by: Anthony Wang <ta180m@pm.me>
2022-03-20 10:53:05 -05:00
456ed42d3e
Run make fmt again, fix err var redeclaration
Signed-off-by: Anthony Wang <ta180m@pm.me>
2022-03-20 10:52:49 -05:00
ea4129e888
Use Gitea JSON library, add assert for pkp
Signed-off-by: Anthony Wang <ta180m@pm.me>
2022-03-19 16:36:44 -05:00
f9e33d97cc
Run make fmt and make generate-swagger
Signed-off-by: Anthony Wang <ta180m@pm.me>
2022-03-19 09:14:08 -05:00
b480c52f60
Change 2021 to 2022, properly format package imports
Signed-off-by: Anthony Wang <ta180m@pm.me>
2022-03-19 08:55:58 -05:00
2a8864fe43
Fix CI checks-backend errors with go mod tidy
Signed-off-by: Anthony Wang <ta180m@pm.me>
2022-03-19 08:54:32 -05:00
Loïc Dachary
b342241abc
activitypub: hack_16834
Signed-off-by: Loïc Dachary <loic@dachary.org>
2022-03-18 17:34:10 -05:00
Loïc Dachary
97fedf2616
activitypub: implement the ReqSignature middleware
Signed-off-by: Loïc Dachary <loic@dachary.org>
2022-03-18 17:34:09 -05:00
Loïc Dachary
15c1f6218c
activitypub: signing http client
Signed-off-by: Loïc Dachary <loic@dachary.org>
2022-03-18 17:34:09 -05:00
Loïc Dachary
e8907c3c9e
activitypub: go-fed conformant Clock instance
Signed-off-by: Loïc Dachary <loic@dachary.org>
2022-03-18 17:34:09 -05:00
Loïc Dachary
678a56fbf8
activitypub: add the public key to Person (#14186)
Refs: https://github.com/go-gitea/gitea/issues/14186

Signed-off-by: Loïc Dachary <loic@dachary.org>
2022-03-18 17:34:09 -05:00
Loïc Dachary
4951af4d99
activitypub: implement /api/v1/activitypub/user/{username} (#14186)
Return informations regarding a Person (as defined in ActivityStreams
https://www.w3.org/TR/activitystreams-vocabulary/#dfn-person).

Refs: https://github.com/go-gitea/gitea/issues/14186

Signed-off-by: Loïc Dachary <loic@dachary.org>
2022-03-18 17:33:29 -05:00
Loïc Dachary
f2db473b0d
go.mod: add go-fed/{httpsig,activity/pub,activity/streams} dependency
go get github.com/go-fed/activity/streams@master
go get github.com/go-fed/activity/pub@master
go get github.com/go-fed/httpsig@master
2022-03-18 17:33:20 -05:00
20 changed files with 1565 additions and 3 deletions

2
go.mod
View file

@ -40,6 +40,8 @@ require (
github.com/go-chi/chi/v5 v5.0.7
github.com/go-chi/cors v1.2.0
github.com/go-enry/go-enry/v2 v2.8.0
github.com/go-fed/activity v1.0.1-0.20220119073622-b14b50eecad0
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.2

10
go.sum
View file

@ -356,6 +356,7 @@ github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ
github.com/cupcake/rdb v0.0.0-20161107195141-43ba34106c76/go.mod h1:vYwsqCOLxGiisLwp9rITslkFNpZD5rz43tf41QFkTWY=
github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E=
github.com/daaku/go.zipexe v1.0.1/go.mod h1:5xWogtqlYnfBXkSB1o9xysukNP9GTvaNkqzUZbt3Bw8=
github.com/dave/jennifer v1.3.0/go.mod h1:fIb+770HOpJ2fmN9EPPKOqm1vMGhB+TwXKMZhrIygKg=
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@ -464,6 +465,11 @@ github.com/go-enry/go-enry/v2 v2.8.0 h1:KMW4mSG+8uUF6FaD3iPkFqyfC5tF8gRrsYImq6yh
github.com/go-enry/go-enry/v2 v2.8.0/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/activity v1.0.1-0.20220119073622-b14b50eecad0 h1:rV8Mp/ChJLd0ZUrS6xMwiP6ZIFpSomffrQOjf4Xyd3M=
github.com/go-fed/activity v1.0.1-0.20220119073622-b14b50eecad0/go.mod h1:v4QoPaAzjWZ8zN2VFVGL5ep9C02mst0hQYHUpQwso4Q=
github.com/go-fed/httpsig v0.1.1-0.20190914113940-c2de3672e5b5/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE=
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=
@ -608,6 +614,8 @@ github.com/go-swagger/go-swagger v0.29.0/go.mod h1:Z4GJzI+bHKKkGB2Ji1rawpi3/ldXX
github.com/go-swagger/scan-repo-boundary v0.0.0-20180623220736-973b3573c013 h1:l9rI6sNaZgNC0LnF3MiE+qTmyBA/tZAg1rtyrGbUMK0=
github.com/go-swagger/scan-repo-boundary v0.0.0-20180623220736-973b3573c013/go.mod h1:b65mBPzqzZWxOZGxSWrqs4GInLIn+u99Q9q7p+GKni0=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-test/deep v1.0.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg=
github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/go-testfixtures/testfixtures/v3 v3.6.1 h1:n4Fv95Exp0D05G6l6CAZv22Ck1EJK0pa0TfPqE4ncSs=
github.com/go-testfixtures/testfixtures/v3 v3.6.1/go.mod h1:Bsb2MoHAfHnNsPpSwAjtOs102mqDuM+1u3nE2OCi0N0=
github.com/gobuffalo/attrs v0.0.0-20190224210810-a9411de4debd/go.mod h1:4duuawTqi2wkkpB4ePgWMaai6/Kc6WEz83bhFwpHzj0=
@ -1642,6 +1650,7 @@ go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8=
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
gocloud.dev v0.19.0/go.mod h1:SmKwiR8YwIMMJvQBKLsC3fHNyMwXLw3PMDO+VVteJMI=
golang.org/x/crypto v0.0.0-20180501155221-613d6eafa307/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
@ -1838,6 +1847,7 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180525142821-c11f84a56e43/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=

View file

@ -0,0 +1,131 @@
// 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"
"github.com/go-fed/activity/pub"
"github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab"
"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)
assert.Contains(t, resp.Body.String(), "@context")
var m map[string]interface{}
DecodeJSON(t, resp, &m)
var person vocab.ActivityStreamsPerson
resolver, _ := streams.NewJSONResolver(func(c context.Context, p vocab.ActivityStreamsPerson) error {
person = p
return nil
})
ctx := context.Background()
err := resolver.Resolve(ctx, m)
assert.NoError(t, err)
assert.Equal(t, "Person", person.GetTypeName())
assert.Equal(t, username, person.GetActivityStreamsName().Begin().GetXMLSchemaString())
keyID := person.GetJSONLDId().GetIRI().String()
assert.Regexp(t, fmt.Sprintf("activitypub/user/%s$", username), keyID)
assert.Regexp(t, fmt.Sprintf("activitypub/user/%s/outbox$", username), person.GetActivityStreamsOutbox().GetIRI().String())
assert.Regexp(t, fmt.Sprintf("activitypub/user/%s/inbox$", username), person.GetActivityStreamsInbox().GetIRI().String())
pkp := person.GetW3IDSecurityV1PublicKey()
assert.NotNil(t, pkp)
publicKeyID := keyID + "/#main-key"
var pkpFound vocab.W3IDSecurityV1PublicKey
for pkpIter := pkp.Begin(); pkpIter != pkp.End(); pkpIter = pkpIter.Next() {
if !pkpIter.IsW3IDSecurityV1PublicKey() {
continue
}
pkValue := pkpIter.Get()
var pkID *url.URL
pkID, err = pub.GetId(pkValue)
if err != nil {
return
}
assert.Equal(t, pkID.String(), publicKeyID)
if pkID.String() != publicKeyID {
continue
}
pkpFound = pkValue
break
}
assert.NotNil(t, pkpFound)
pkPemProp := pkpFound.GetW3IDSecurityV1PublicKeyPem()
assert.NotNil(t, pkPemProp)
assert.True(t, pkPemProp.IsXMLSchemaString())
pubKeyPem := pkPemProp.Get()
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(), "GetUserByName")
})
}
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.Database.LogSQL = true
setting.AppURL = srv.URL
defer func() {
setting.Federation.Enabled = false
setting.Database.LogSQL = false
setting.AppURL = appURL
}()
username1 := "user1"
user1, err := user_model.GetUserByName(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)
})
}

View file

@ -0,0 +1,128 @@
// 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"
"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/activity/pub"
"github.com/go-fed/httpsig"
)
const (
// ActivityStreamsContentType const
ActivityStreamsContentType = `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`
)
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 {
clock pub.Clock
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
}
algos := make([]httpsig.Algorithm, len(setting.Federation.Algorithms))
for i, algo := range setting.Federation.Algorithms {
algos[i] = httpsig.Algorithm(algo)
}
clock, err := NewClock()
if 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{
clock: clock,
client: &http.Client{
Transport: &http.Transport{
Proxy: proxy.Proxy(),
},
},
algs: algos,
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("Accept-Charset", "utf-8")
req.Header.Add("Date", fmt.Sprintf("%s GMT", c.clock.Now().UTC().Format(time.RFC1123)))
signer, _, err := httpsig.NewSigner(c.algs, c.digestAlg, c.postHeaders, httpsig.Signature, 60)
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
}

View file

@ -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))
}

View file

@ -0,0 +1,27 @@
// 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 (
"time"
"github.com/go-fed/activity/pub"
)
var _ pub.Clock = &Clock{}
// Clock struct
type Clock struct{}
// NewClock function
func NewClock() (c *Clock, err error) {
c = &Clock{}
return
}
// Now function
func (c *Clock) Now() time.Time {
return time.Now().Local()
}

View file

@ -0,0 +1,30 @@
// 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 (
"regexp"
"testing"
"time"
"code.gitea.io/gitea/modules/setting"
"github.com/stretchr/testify/assert"
)
func TestClock(t *testing.T) {
DefaultUILocation := setting.DefaultUILocation
defer func() {
setting.DefaultUILocation = DefaultUILocation
}()
c, err := NewClock()
assert.NoError(t, err)
setting.DefaultUILocation, err = time.LoadLocation("UTC")
assert.NoError(t, err)
assert.Regexp(t, regexp.MustCompile(`\+0000$`), c.Now().Format(time.Layout))
setting.DefaultUILocation, err = time.LoadLocation("Europe/Paris")
assert.NoError(t, err)
assert.Regexp(t, regexp.MustCompile(`\+0[21]00$`), c.Now().Format(time.Layout))
}

View file

@ -0,0 +1,416 @@
// 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 (
"context"
"errors"
"net/url"
"sync"
"github.com/go-fed/activity/pub"
"github.com/go-fed/activity/streams/vocab"
)
type myDB struct {
// The content of our app, keyed by ActivityPub ID.
content *sync.Map
// Enables mutations. A sync.Mutex per ActivityPub ID.
locks *sync.Map
// The host domain of our service, for detecting ownership.
hostname string
}
// Our content map will store this data.
type content struct {
// The payload of the data: vocab.Type is any type understood by Go-Fed.
data vocab.Type
// If true, belongs to our local user and not a federated peer. This is
// recommended for a solution that just indiscriminately puts everything
// into a single "table", like this in-memory solution.
isLocal bool
}
func (m *myDB) Lock(c context.Context,
id *url.URL) error {
// Before any other Database methods are called, the relevant `id`
// entries are locked to allow for fine-grained concurrency.
// Strategy: create a new lock, if stored, continue. Otherwise, lock the
// existing mutex.
mu := &sync.Mutex{}
mu.Lock() // Optimistically lock if we do store it.
i, loaded := m.locks.LoadOrStore(id.String(), mu)
if loaded {
mu = i.(*sync.Mutex)
mu.Lock()
}
return nil
}
func (m *myDB) Unlock(c context.Context,
id *url.URL) error {
// Once Go-Fed is done calling Database methods, the relevant `id`
// entries are unlocked.
i, ok := m.locks.Load(id.String())
if !ok {
return errors.New("Missing an id in Unlock")
}
mu := i.(*sync.Mutex)
mu.Unlock()
return nil
}
func (m *myDB) Owns(c context.Context,
id *url.URL) (owns bool, err error) {
// Owns just determines if the ActivityPub id is owned by this server.
// In a real implementation, consider something far more robust than
// this string comparison.
return id.Host == m.hostname, nil
}
func (m *myDB) Exists(c context.Context,
id *url.URL) (exists bool, err error) {
// Do we have this `id`?
_, exists = m.content.Load(id.String())
return
}
func (m *myDB) Get(c context.Context,
id *url.URL) (value vocab.Type, err error) {
// Our goal is to return what we have at that `id`. Returns an error if
// not found.
iCon, exists := m.content.Load(id.String())
if !exists {
err = errors.New("Get failed")
return
}
// Extract the data from our `content` type.
con := iCon.(*content)
return con.data, nil
}
func (m *myDB) Create(c context.Context,
asType vocab.Type) error {
// Create a payload in our in-memory map. The thing could be a local or
// a federated peer's data. We can re-use the `Owns` call to set the
// metadata on our `content`.
id, err := pub.GetId(asType)
if err != nil {
return err
}
owns, err := m.Owns(c, id)
if err != nil {
return err
}
con := &content {
data: asType,
isLocal: owns,
}
m.content.Store(id.String(), con)
return nil
}
func (m *myDB) Update(c context.Context,
asType vocab.Type) error {
// Replace a payload in our in-memory map. The thing could be a local or
// a federated peer's data. Since we are using a map and not a solution
// like SQL, we can simply do what `Create` does: overwrite it.
//
// Note that an actor's followers, following, and liked collections are
// never Created, only Updated.
return m.Create(c, asType)
}
func (m *myDB) Delete(c context.Context,
id *url.URL) error {
// Remove a payload in our in-memory map.
m.content.Delete(id.String())
return nil
}
// getOrderedCollection is a helper method to fetch an
// OrderedCollection. It is not implemented in this tutorial, and uses
// the map m.content to do the lookup.
func (m *myDB) getOrderedCollection(inbox *url.URL) (vocab.ActivityStreamsOrderedCollection, error) {
val, _ := m.content.Load(inbox.String())
return val.(*content).data.(vocab.ActivityStreamsOrderedCollection), nil
}
// getOrderedCollectionPage is a helper method to fetch an
// OrderedCollectionPage. It is not implemented in this tutorial, and
// uses the map m.content to do the lookup and any conversions if
// needed. The database can get fancy and use query parameters in the
// `inboxIRI` to paginate appropriately.
func (m *myDB) getOrderedCollectionPage(inboxIRI *url.URL) (vocab.ActivityStreamsOrderedCollectionPage, error) {
val, _ := m.content.Load(inboxIRI.String())
return val.(*content).data.(vocab.ActivityStreamsOrderedCollectionPage), nil
}
// applyDiffOrderedCollection is a helper method to apply changes due
// to an edited OrderedCollectionPage. Implementation is left as an
// exercise for the reader.
func (m *myDB) applyDiffOrderedCollection(storedInbox vocab.ActivityStreamsOrderedCollection, inbox vocab.ActivityStreamsOrderedCollectionPage) vocab.ActivityStreamsOrderedCollectionPage {
// TODO
return inbox
}
// saveToContent is a helper method to save an
// ActivityStream type. Implementation is left as an exercise for the
// reader.
func (m *myDB) saveToContent(c context.Context, inbox vocab.ActivityStreamsOrderedCollectionPage) error {
m.Create(c, inbox)
return nil
}
func (m *myDB) InboxContains(c context.Context,
inbox,
id *url.URL) (contains bool, err error) {
// Our goal is to see if the `inbox`, which is an OrderedCollection,
// contains an element in its `ordered_items` property that has a
// matching `id`
contains = false
var oc vocab.ActivityStreamsOrderedCollection
// getOrderedCollection is a helper method to fetch an
// OrderedCollection. It is not implemented in this tutorial, and uses
// the map m.content to do the lookup.
oc, err = m.getOrderedCollection(inbox)
if err != nil {
return
}
// Next, we use the ActivityStreams vocabulary to obtain the
// ordered_items property of the OrderedCollection type.
oi := oc.GetActivityStreamsOrderedItems()
// Properties may be nil, if non-existent!
if oi == nil {
return
}
// Finally, loop through each item in the ordered_items property and see
// if the element's id matches the desired id.
for iter := oi.Begin(); iter != oi.End(); iter = iter.Next() {
var iterId *url.URL
iterId, err = pub.ToId(iter)
if err != nil {
return
}
if iterId.String() == id.String() {
contains = true
return
}
}
return
}
func (m *myDB) GetInbox(c context.Context,
inboxIRI *url.URL) (inbox vocab.ActivityStreamsOrderedCollectionPage, err error) {
// The goal here is to fetch an inbox at the specified IRI.
// getOrderedCollectionPage is a helper method to fetch an
// OrderedCollectionPage. It is not implemented in this tutorial, and
// uses the map m.content to do the lookup and any conversions if
// needed. The database can get fancy and use query parameters in the
// `inboxIRI` to paginate appropriately.
return m.getOrderedCollectionPage(inboxIRI)
}
func (m *myDB) SetInbox(c context.Context,
inbox vocab.ActivityStreamsOrderedCollectionPage) error {
// The goal here is to set an inbox at the specified IRI, with any
// changes to the page made persistent. Since the inbox has been Locked,
// it is OK to assume that no other concurrent goroutine has changed the
// inbox in the meantime.
// getOrderedCollection is a helper method to fetch an
// OrderedCollection. It is not implemented in this tutorial, and
// uses the map m.content to do the lookup.
storedInbox, err := m.getOrderedCollection(inbox.GetJSONLDId().GetIRI())
if err != nil {
return err
}
// applyDiffOrderedCollection is a helper method to apply changes due
// to an edited OrderedCollectionPage. Implementation is left as an
// exercise for the reader.
updatedInbox := m.applyDiffOrderedCollection(storedInbox, inbox)
// saveToContent is a helper method to save an
// ActivityStream type. Implementation is left as an exercise for the
// reader.
return m.saveToContent(c, updatedInbox)
}
func (m *myDB) GetOutbox(c context.Context,
outboxIRI *url.URL) (inbox vocab.ActivityStreamsOrderedCollectionPage, err error) {
// Similar to `GetInbox`, but for the outbox. See `GetInbox`.
return m.getOrderedCollectionPage(outboxIRI)
}
func (m *myDB) SetOutbox(c context.Context,
outbox vocab.ActivityStreamsOrderedCollectionPage) error {
// Similar to `SetInbox`, but for the outbox. See `SetInbox`.
// The goal here is to set an outbox at the specified IRI, with any
// changes to the page made persistent. Since the outbox has been Locked,
// it is OK to assume that no other concurrent goroutine has changed the
// outbox in the meantime.
// getOrderedCollection is a helper method to fetch an
// OrderedCollection. It is not implemented in this tutorial, and
// uses the map m.content to do the lookup.
storedOutbox, err := m.getOrderedCollection(outbox.GetJSONLDId().GetIRI())
if err != nil {
return err
}
// applyDiffOrderedCollection is a helper method to apply changes due
// to an edited OrderedCollectionPage. Implementation is left as an
// exercise for the reader.
updatedOutbox := m.applyDiffOrderedCollection(storedOutbox, outbox)
// saveToContent is a helper method to save an
// ActivityStream type. Implementation is left as an exercise for the
// reader.
return m.saveToContent(c, updatedOutbox)
}
func (m *myDB) ActorForOutbox(c context.Context,
outboxIRI *url.URL) (actorIRI *url.URL, err error) {
// Given the `outboxIRI`, determine the IRI of the actor that owns
// that outbox. Will only be used for actors on this local server.
// Implementation left as an exercise to the reader.
return outboxIRI, nil
}
func (m *myDB) ActorForInbox(c context.Context,
inboxIRI *url.URL) (actorIRI *url.URL, err error) {
// Given the `inboxIRI`, determine the IRI of the actor that owns
// that inbox. Will only be used for actors on this local server.
// Implementation left as an exercise to the reader.
return inboxIRI, nil
}
func (m *myDB) OutboxForInbox(c context.Context,
inboxIRI *url.URL) (outboxIRI *url.URL, err error) {
// Given the `inboxIRI`, determine the IRI of the outbox owned
// by the same actor that owns the inbox. Will only be used for actors
// on this local server. Implementation left as an exercise to the
// reader.
return url.Parse(inboxIRI.String()[:len(inboxIRI.String())-5] + "outbox")
}
// https://github.com/go-fed/activity/pull/153
func (m *myDB) InboxForActor(c context.Context, actorIRI *url.URL) (inboxIRI *url.URL, err error) {
return nil, nil
}
func (m *myDB) NewID(c context.Context,
t vocab.Type) (id *url.URL, err error) {
// Generate a new `id` for the ActivityStreams object `t`.
// You can be fancy and put different types authored by different folks
// along different paths. Or just generate a GUID. Implementation here
// is left as an exercise for the reader.
return t.GetJSONLDId().GetIRI(), nil
}
// getPerson is a helper method that returns an actor on this server
// with a Person ActivityStreams type. It is not implemented in this tutorial.
func (m *myDB) getPerson(actorIRI *url.URL) (person vocab.ActivityStreamsPerson, err error) {
val, _ := m.content.Load(actorIRI.String())
return val.(*content).data.(vocab.ActivityStreamsPerson), nil
}
func (m *myDB) Followers(c context.Context,
actorIRI *url.URL) (followers vocab.ActivityStreamsCollection, err error) {
// Get the followers collection from the actor with `actorIRI`.
// getPerson is a helper method that returns an actor on this server
// with a Person ActivityStreams type. It is not implemented in this tutorial.
var person vocab.ActivityStreamsPerson
person, err = m.getPerson(actorIRI)
if err != nil {
return
}
// Let's get their followers property, ensure it exists, and then
// fetch it with a familiar helper method.
f := person.GetActivityStreamsFollowers()
if f == nil {
err = errors.New("no followers collection")
return
}
// Note: at this point f is not the OrderedCollection itself yet. It is
// an opaque box (it could be an IRI, an OrderedCollection, or something
// extending an OrderedCollection).
followersId, err := pub.ToId(f)
if err != nil {
return
}
val, err := m.getOrderedCollection(followersId)
followers = val.(vocab.ActivityStreamsCollection)
return
}
func (m *myDB) Following(c context.Context,
actorIRI *url.URL) (following vocab.ActivityStreamsCollection, err error) {
// Get the following collection from the actor with `actorIRI`.
// Implementation is similar to `Followers`. See `Followers`.
// getPerson is a helper method that returns an actor on this server
// with a Person ActivityStreams type. It is not implemented in this tutorial.
var person vocab.ActivityStreamsPerson
person, err = m.getPerson(actorIRI)
if err != nil {
return
}
// Let's get their following property, ensure it exists, and then
// fetch it with a familiar helper method.
f := person.GetActivityStreamsFollowing()
if f == nil {
err = errors.New("no following collection")
return
}
// Note: at this point f is not the OrderedCollection itself yet. It is
// an opaque box (it could be an IRI, an OrderedCollection, or something
// extending an OrderedCollection).
followingId, err := pub.ToId(f)
if err != nil {
return
}
val, err := m.getOrderedCollection(followingId)
following = val.(vocab.ActivityStreamsCollection)
return
}
func (m *myDB) Liked(c context.Context,
actorIRI *url.URL) (liked vocab.ActivityStreamsCollection, err error) {
// Get the liked collection from the actor with `actorIRI`.
// Implementation is similar to `Followers`. See `Followers`.
// getPerson is a helper method that returns an actor on this server
// with a Person ActivityStreams type. It is not implemented in this tutorial.
var person vocab.ActivityStreamsPerson
person, err = m.getPerson(actorIRI)
if err != nil {
return
}
// Let's get their liked property, ensure it exists, and then
// fetch it with a familiar helper method.
f := person.GetActivityStreamsLiked()
if f == nil {
err = errors.New("no liked collection")
return
}
// Note: at this point f is not the OrderedCollection itself yet. It is
// an opaque box (it could be an IRI, an OrderedCollection, or something
// extending an OrderedCollection).
likedId, err := pub.ToId(f)
if err != nil {
return
}
val, err := m.getOrderedCollection(likedId)
liked = val.(vocab.ActivityStreamsCollection)
return
}

View file

@ -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 activitypub
import (
"path/filepath"
"testing"
"code.gitea.io/gitea/models/unittest"
)
func TestMain(m *testing.M) {
unittest.MainTest(m, filepath.Join("..", ".."))
}

View file

@ -0,0 +1,121 @@
// 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 (
"context"
"net/http"
"net/url"
"time"
user_model "code.gitea.io/gitea/models/user"
"github.com/go-fed/activity/pub"
"github.com/go-fed/activity/streams/vocab"
"github.com/go-fed/httpsig"
)
type myService struct {}
func (*myService) AuthenticateGetInbox(c context.Context,
w http.ResponseWriter,
r *http.Request) (out context.Context, authenticated bool, err error) {
return
}
func (*myService) AuthenticateGetOutbox(c context.Context,
w http.ResponseWriter,
r *http.Request) (out context.Context, authenticated bool, err error) {
return
}
func (*myService) GetOutbox(c context.Context,
r *http.Request) (vocab.ActivityStreamsOrderedCollectionPage, error) {
// TODO
return db.GetOutbox(c, r.URL)
}
// https://go-fed.org/ref/activity/pub#Transports
func (*myService) NewTransport(c context.Context, actorBoxIRI *url.URL, gofedAgent string) (t pub.Transport, err error) {
prefs := []httpsig.Algorithm{httpsig.RSA_SHA256}
digestPref := httpsig.DigestSha256
getHeadersToSign := []string{httpsig.RequestTarget, "Date"}
postHeadersToSign := []string{httpsig.RequestTarget, "Date", "Digest"}
// Using github.com/go-fed/httpsig for HTTP Signatures:
getSigner, _, err := httpsig.NewSigner(prefs, digestPref, getHeadersToSign, httpsig.Signature, 3600)
postSigner, _, err := httpsig.NewSigner(prefs, digestPref, postHeadersToSign, httpsig.Signature, 3600)
user, _ := user_model.GetUserByName(actorBoxIRI.String())
pubKeyId, privKey, err := GetKeyPair(user)
client := &http.Client{
Timeout: time.Second * 30,
}
t = pub.NewHttpSigTransport(
client,
"example.com",
&Clock{},
getSigner,
postSigner,
pubKeyId,
privKey)
return
}
func (*myService) PostInboxRequestBodyHook(c context.Context,
r *http.Request,
activity pub.Activity) (context.Context, error) {
// TODO
return nil, nil
}
func (*myService) AuthenticatePostInbox(c context.Context,
w http.ResponseWriter,
r *http.Request) (out context.Context, authenticated bool, err error) {
// TODO
return
}
func (*myService) Blocked(c context.Context,
actorIRIs []*url.URL) (blocked bool, err error) {
// TODO
return
}
func (*myService) FederatingCallbacks(c context.Context) (wrapped pub.FederatingWrappedCallbacks, other []interface{}, err error) {
// Return the default ActivityPub callbacks, and nothing in `other`.
return
}
func (*myService) DefaultCallback(c context.Context,
activity pub.Activity) error {
// TODO
return nil
}
func (*myService) MaxInboxForwardingRecursionDepth(c context.Context) int {
// TODO
return 10
}
func (*myService) MaxDeliveryRecursionDepth(c context.Context) int {
// TODO
return 10
}
func (*myService) FilterForwarding(c context.Context,
potentialRecipients []*url.URL,
a pub.Activity) (filteredRecipients []*url.URL, err error) {
// TODO
return
}
func (*myService) GetInbox(c context.Context,
r *http.Request) (vocab.ActivityStreamsOrderedCollectionPage, error) {
// TODO
return db.GetInbox(c, r.URL)
}
func (*myService) Now() time.Time {
return time.Now()
}

View file

@ -0,0 +1,33 @@
// 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 (
"sync"
"github.com/go-fed/activity/pub"
)
var (
user_actor pub.Actor
db *myDB
)
func NewUserActor() {
s := &myService{}
db = &myDB{
content: &sync.Map{},
locks: &sync.Map{},
hostname: "localhost",
}
user_actor = pub.NewFederatingActor(/* CommonBehavior */ s,
/* FederatingProtocol */ s,
/* Database */ db,
/* Clock */ s)
}
func GetUserActor() pub.Actor {
return user_actor
}

View file

@ -0,0 +1,44 @@
// 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
func GetKeyPair(user *user_model.User) (pub, priv string, err error) {
var settings map[string]*user_model.Setting
if settings, err = user_model.GetUserSettings(user.ID, []string{"activitypub_privpem", "activitypub_pubpem"}); err != nil {
return
} else if len(settings) == 0 {
if priv, pub, err = GenerateKeyPair(); err != nil {
return
}
if err = user_model.SetUserSetting(user.ID, "activitypub_privpem", priv); err != nil {
return
}
if err = user_model.SetUserSetting(user.ID, "activitypub_pubpem", pub); err != nil {
return
}
return
} else {
priv = settings["activitypub_privpem"].SettingValue
pub = settings["activitypub_pubpem"].SettingValue
return
}
}
// GetPublicKey function
func GetPublicKey(user *user_model.User) (pub string, err error) {
pub, _, err = GetKeyPair(user)
return
}
// GetPrivateKey function
func GetPrivateKey(user *user_model.User) (priv string, err error) {
_, priv, err = GetKeyPair(user)
return
}

View file

@ -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)
}

View file

@ -4,19 +4,34 @@
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
Enabled bool
Algorithms []string
DigestAlgorithm string
GetHeaders []string
PostHeaders []string
}{
Enabled: true,
Enabled: true,
Algorithms: []string{"rsa-sha256", "rsa-sha512"},
DigestAlgorithm: "SHA-256",
GetHeaders: []string{"(request-target)", "Date"},
PostHeaders: []string{"(request-target)", "Date", "Digest"},
}
)
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
}
}

View file

@ -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"`
}

View file

@ -0,0 +1,183 @@
// 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"
"net/url"
"strings"
"code.gitea.io/gitea/modules/activitypub"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/routers/api/v1/user"
"github.com/go-fed/activity/streams"
)
// Person function
func Person(ctx *context.APIContext) {
// swagger:operation GET /activitypub/user/{username} activitypub activitypubPerson
// ---
// summary: Returns the person
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: username of the user
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/ActivityPub"
user := user.GetUserByParamsName(ctx, "username")
if user == nil {
return
}
username := ctx.Params("username")
person := streams.NewActivityStreamsPerson()
id := streams.NewJSONLDIdProperty()
link := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ctx.Req.URL.EscapedPath(), "/")
idIRI, _ := url.Parse(link)
id.SetIRI(idIRI)
person.SetJSONLDId(id)
name := streams.NewActivityStreamsNameProperty()
name.AppendXMLSchemaString(username)
person.SetActivityStreamsName(name)
ibox := streams.NewActivityStreamsInboxProperty()
urlObject, _ := url.Parse(link + "/inbox")
ibox.SetIRI(urlObject)
person.SetActivityStreamsInbox(ibox)
obox := streams.NewActivityStreamsOutboxProperty()
urlObject, _ = url.Parse(link + "/outbox")
obox.SetIRI(urlObject)
person.SetActivityStreamsOutbox(obox)
publicKeyProp := streams.NewW3IDSecurityV1PublicKeyProperty()
publicKeyType := streams.NewW3IDSecurityV1PublicKey()
pubKeyIDProp := streams.NewJSONLDIdProperty()
pubKeyIRI, _ := url.Parse(link + "/#main-key")
pubKeyIDProp.SetIRI(pubKeyIRI)
publicKeyType.SetJSONLDId(pubKeyIDProp)
ownerProp := streams.NewW3IDSecurityV1OwnerProperty()
ownerProp.SetIRI(idIRI)
publicKeyType.SetW3IDSecurityV1Owner(ownerProp)
publicKeyPemProp := streams.NewW3IDSecurityV1PublicKeyPemProperty()
if publicKeyPem, err := activitypub.GetPublicKey(user); err != nil {
ctx.Error(http.StatusInternalServerError, "GetPublicKey", err)
return
} else {
publicKeyPemProp.Set(publicKeyPem)
}
publicKeyType.SetW3IDSecurityV1PublicKeyPem(publicKeyPemProp)
publicKeyProp.AppendW3IDSecurityV1PublicKey(publicKeyType)
person.SetW3IDSecurityV1PublicKey(publicKeyProp)
jsonmap, err := streams.Serialize(person)
if err != nil {
ctx.Error(http.StatusInternalServerError, "Serialize", err)
}
ctx.JSON(http.StatusOK, jsonmap)
}
// PersonInboxGet function
func PersonInboxGet(ctx *context.APIContext) {
// swagger:operation GET /activitypub/user/{username}/outbox activitypub activitypubPersonInbox
// ---
// summary: Returns the inbox
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: username of the user
// type: string
// required: true
// responses:
// responses:
// "200":
// "$ref": "#/responses/ActivityPub"
ctx.Status(http.StatusOK)
activitypub.GetUserActor().GetInbox(ctx, ctx.Resp, ctx.Req)
}
// PersonInboxPost function
func PersonInboxPost(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:
// "200":
// "$ref": "#/responses/ActivityPub"
ctx.Status(http.StatusOK)
activitypub.GetUserActor().PostInbox(ctx, ctx.Resp, ctx.Req)
}
// PersonOutboxGet function
func PersonOutboxGet(ctx *context.APIContext) {
// swagger:operation GET /activitypub/user/{username}/outbox activitypub activitypubPersonOutbox
// ---
// summary: Returns the outbox
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: username of the user
// type: string
// required: true
// responses:
// responses:
// "200":
// "$ref": "#/responses/ActivityPub"
ctx.Status(http.StatusOK)
activitypub.GetUserActor().GetOutbox(ctx, ctx.Resp, ctx.Req)
}
// PersonOutboxPost function
func PersonOutboxPost(ctx *context.APIContext) {
// swagger:operation POST /activitypub/user/{username}/outbox activitypub activitypubPersonOutbox
// ---
// summary: Send to the outbox
// produces:
// - application/json
// parameters:
// - name: username
// in: path
// description: username of the user
// type: string
// required: true
// responses:
// responses:
// "200":
// "$ref": "#/responses/ActivityPub"
ctx.Status(http.StatusOK)
activitypub.GetUserActor().PostOutbox(ctx, ctx.Resp, ctx.Req)
}

View file

@ -0,0 +1,151 @@
// 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 (
"context"
"crypto"
"crypto/x509"
"encoding/pem"
"fmt"
"io"
"net/http"
"net/url"
"time"
"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/json"
"code.gitea.io/gitea/modules/setting"
"github.com/go-fed/activity/pub"
"github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab"
"github.com/go-fed/httpsig"
)
type publicKeyer interface {
GetW3IDSecurityV1PublicKey() vocab.W3IDSecurityV1PublicKeyProperty
}
func getPublicKeyFromResponse(ctx context.Context, b []byte, keyID *url.URL) (p crypto.PublicKey, err error) {
m := make(map[string]interface{})
err = json.Unmarshal(b, &m)
if err != nil {
return
}
var t vocab.Type
t, err = streams.ToType(ctx, m)
if err != nil {
return
}
pker, ok := t.(publicKeyer)
if !ok {
err = fmt.Errorf("ActivityStreams type cannot be converted to one known to have publicKey property: %T", t)
return
}
pkp := pker.GetW3IDSecurityV1PublicKey()
if pkp == nil {
err = fmt.Errorf("publicKey property is not provided")
return
}
var pkpFound vocab.W3IDSecurityV1PublicKey
for pkpIter := pkp.Begin(); pkpIter != pkp.End(); pkpIter = pkpIter.Next() {
if !pkpIter.IsW3IDSecurityV1PublicKey() {
continue
}
pkValue := pkpIter.Get()
var pkID *url.URL
pkID, err = pub.GetId(pkValue)
if err != nil {
return
}
if pkID.String() != keyID.String() {
continue
}
pkpFound = pkValue
break
}
if pkpFound == nil {
err = fmt.Errorf("cannot find publicKey with id: %s in %s", keyID, b)
return
}
pkPemProp := pkpFound.GetW3IDSecurityV1PublicKeyPem()
if pkPemProp == nil || !pkPemProp.IsXMLSchemaString() {
err = fmt.Errorf("publicKeyPem property is not provided or it is not embedded as a value")
return
}
pubKeyPem := pkPemProp.Get()
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("Accept-Charset", "utf-8")
clock, err := activitypub.NewClock()
if err != nil {
return
}
req.Header("Date", fmt.Sprintf("%s GMT", clock.Now().UTC().Format(time.RFC1123)))
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(resp.Body)
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
}
pKey, err := getPublicKeyFromResponse(*ctx, b, idIRI)
if err != nil {
return
}
// 3. Verify the other actor's key
algo := httpsig.Algorithm(setting.Federation.Algorithms[0])
authenticated = nil == v.Verify(pKey, algo)
return
}
// ReqSignature function
func ReqSignature() func(ctx *gitea_context.APIContext) {
return func(ctx *gitea_context.APIContext) {
if authenticated, err := verifyHTTPSignatures(ctx); err != nil {
ctx.Error(http.StatusInternalServerError, "verifyHttpSignatures", err)
} else if !authenticated {
ctx.Error(http.StatusForbidden, "reqSignature", "request signature verification failed")
}
}
}

View file

@ -79,6 +79,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"
@ -597,6 +598,13 @@ func Routes(sessioner func(http.Handler) http.Handler) *web.Route {
m.Get("/version", misc.Version)
if setting.Federation.Enabled {
m.Get("/nodeinfo", misc.NodeInfo)
m.Group("/activitypub", func() {
m.Get("/user/{username}", activitypub.Person)
m.Get("/user/{username}/inbox", activitypub.ReqSignature(), activitypub.PersonInboxGet)
m.Post("/user/{username}/inbox", activitypub.ReqSignature(), activitypub.PersonInboxPost)
m.Get("/user/{username}/outbox", activitypub.ReqSignature(), activitypub.PersonOutboxGet)
m.Post("/user/{username}/outbox", activitypub.ReqSignature(), activitypub.PersonOutboxPost)
})
}
m.Get("/signing-key.gpg", misc.SigningKey)
m.Post("/markdown", bind(api.MarkdownOption{}), misc.Markdown)

View file

@ -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"`
}

View file

@ -23,6 +23,132 @@
},
"basePath": "{{AppSubUrl | JSEscape | Safe}}/api/v1",
"paths": {
"/activitypub/user/{username}": {
"get": {
"produces": [
"application/json"
],
"tags": [
"activitypub"
],
"summary": "Returns the person",
"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": {
"get": {
"produces": [
"application/json"
],
"tags": [
"activitypub"
],
"summary": "Returns the inbox",
"operationId": "activitypubPersonInbox",
"parameters": [
{
"type": "string",
"description": "username of the user",
"name": "username",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/ActivityPub"
}
}
},
"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": {
"200": {
"$ref": "#/responses/ActivityPub"
}
}
}
},
"/activitypub/user/{username}/outbox": {
"get": {
"produces": [
"application/json"
],
"tags": [
"activitypub"
],
"summary": "Returns the outbox",
"operationId": "activitypubPersonOutbox",
"parameters": [
{
"type": "string",
"description": "username of the user",
"name": "username",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/ActivityPub"
}
}
},
"post": {
"produces": [
"application/json"
],
"tags": [
"activitypub"
],
"summary": "Send to the outbox",
"operationId": "activitypubPersonOutbox",
"parameters": [
{
"type": "string",
"description": "username of the user",
"name": "username",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/ActivityPub"
}
}
}
},
"/admin/cron": {
"get": {
"produces": [
@ -12700,6 +12826,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",
@ -18235,6 +18372,12 @@
}
}
},
"ActivityPub": {
"description": "ActivityPub",
"schema": {
"$ref": "#/definitions/ActivityPub"
}
},
"AnnotatedTag": {
"description": "AnnotatedTag",
"schema": {