Compare commits
31 commits
main
...
feature-in
Author | SHA1 | Date | |
---|---|---|---|
d602177eed | |||
530175e030 | |||
49b8518b11 | |||
fccc924403 | |||
325448fadf | |||
02f28f01f0 | |||
4307cb9207 | |||
c9517a2a3a | |||
7ea5e108a5 | |||
fdae736f22 | |||
1da0d49de7 | |||
65016b2664 | |||
d1a53f7d6a | |||
373a84a8e2 | |||
21c56f8e94 | |||
3ed4a71a4c | |||
46973f99fa | |||
ebef769703 | |||
d75809aeee | |||
456ed42d3e | |||
ea4129e888 | |||
f9e33d97cc | |||
b480c52f60 | |||
2a8864fe43 | |||
|
b342241abc | ||
|
97fedf2616 | ||
|
15c1f6218c | ||
|
e8907c3c9e | ||
|
678a56fbf8 | ||
|
4951af4d99 | ||
|
f2db473b0d |
20 changed files with 1565 additions and 3 deletions
2
go.mod
2
go.mod
|
@ -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
10
go.sum
|
@ -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=
|
||||
|
|
131
integrations/api_activitypub_person_test.go
Normal file
131
integrations/api_activitypub_person_test.go
Normal 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)
|
||||
})
|
||||
}
|
128
modules/activitypub/client.go
Normal file
128
modules/activitypub/client.go
Normal 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
|
||||
}
|
49
modules/activitypub/client_test.go
Normal file
49
modules/activitypub/client_test.go
Normal 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))
|
||||
}
|
27
modules/activitypub/clock.go
Normal file
27
modules/activitypub/clock.go
Normal 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()
|
||||
}
|
30
modules/activitypub/clock_test.go
Normal file
30
modules/activitypub/clock_test.go
Normal 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))
|
||||
}
|
416
modules/activitypub/database.go
Normal file
416
modules/activitypub/database.go
Normal 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
|
||||
}
|
16
modules/activitypub/main_test.go
Normal file
16
modules/activitypub/main_test.go
Normal 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("..", ".."))
|
||||
}
|
121
modules/activitypub/service.go
Normal file
121
modules/activitypub/service.go
Normal 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()
|
||||
}
|
33
modules/activitypub/user_actor.go
Normal file
33
modules/activitypub/user_actor.go
Normal 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
|
||||
}
|
44
modules/activitypub/user_settings.go
Normal file
44
modules/activitypub/user_settings.go
Normal 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
|
||||
}
|
29
modules/activitypub/user_settings_test.go
Normal file
29
modules/activitypub/user_settings_test.go
Normal 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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
10
modules/structs/activitypub.go
Normal file
10
modules/structs/activitypub.go
Normal 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"`
|
||||
}
|
183
routers/api/v1/activitypub/person.go
Normal file
183
routers/api/v1/activitypub/person.go
Normal 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)
|
||||
}
|
151
routers/api/v1/activitypub/reqsignature.go
Normal file
151
routers/api/v1/activitypub/reqsignature.go
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
16
routers/api/v1/swagger/activitypub.go
Normal file
16
routers/api/v1/swagger/activitypub.go
Normal 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"`
|
||||
}
|
|
@ -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": {
|
||||
|
|
Reference in a new issue