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/chi/v5 v5.0.7
|
||||||
github.com/go-chi/cors v1.2.0
|
github.com/go-chi/cors v1.2.0
|
||||||
github.com/go-enry/go-enry/v2 v2.8.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-billy/v5 v5.3.1
|
||||||
github.com/go-git/go-git/v5 v5.4.3-0.20210630082519-b4368b2a2ca4
|
github.com/go-git/go-git/v5 v5.4.3-0.20210630082519-b4368b2a2ca4
|
||||||
github.com/go-ldap/ldap/v3 v3.4.2
|
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/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.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E=
|
||||||
github.com/daaku/go.zipexe v1.0.1/go.mod h1:5xWogtqlYnfBXkSB1o9xysukNP9GTvaNkqzUZbt3Bw8=
|
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 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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
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-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 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo=
|
||||||
github.com/go-enry/go-oniguruma v1.2.1/go.mod h1:bWDhYP+S6xZQgiRL7wlTScFYBe023B6ilRZbCAD5Hf4=
|
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 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4=
|
||||||
github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E=
|
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=
|
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 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-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-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 h1:n4Fv95Exp0D05G6l6CAZv22Ck1EJK0pa0TfPqE4ncSs=
|
||||||
github.com/go-testfixtures/testfixtures/v3 v3.6.1/go.mod h1:Bsb2MoHAfHnNsPpSwAjtOs102mqDuM+1u3nE2OCi0N0=
|
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=
|
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=
|
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
|
||||||
gocloud.dev v0.19.0/go.mod h1:SmKwiR8YwIMMJvQBKLsC3fHNyMwXLw3PMDO+VVteJMI=
|
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-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-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-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||||
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/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-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 h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
|
||||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
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-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-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/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
|
package setting
|
||||||
|
|
||||||
import "code.gitea.io/gitea/modules/log"
|
import (
|
||||||
|
"code.gitea.io/gitea/modules/log"
|
||||||
|
|
||||||
|
"github.com/go-fed/httpsig"
|
||||||
|
)
|
||||||
|
|
||||||
// Federation settings
|
// Federation settings
|
||||||
var (
|
var (
|
||||||
Federation = struct {
|
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() {
|
func newFederationService() {
|
||||||
if err := Cfg.Section("federation").MapTo(&Federation); err != nil {
|
if err := Cfg.Section("federation").MapTo(&Federation); err != nil {
|
||||||
log.Fatal("Failed to map Federation settings: %v", err)
|
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"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
api "code.gitea.io/gitea/modules/structs"
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
"code.gitea.io/gitea/modules/web"
|
"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/admin"
|
||||||
"code.gitea.io/gitea/routers/api/v1/misc"
|
"code.gitea.io/gitea/routers/api/v1/misc"
|
||||||
"code.gitea.io/gitea/routers/api/v1/notify"
|
"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)
|
m.Get("/version", misc.Version)
|
||||||
if setting.Federation.Enabled {
|
if setting.Federation.Enabled {
|
||||||
m.Get("/nodeinfo", misc.NodeInfo)
|
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.Get("/signing-key.gpg", misc.SigningKey)
|
||||||
m.Post("/markdown", bind(api.MarkdownOption{}), misc.Markdown)
|
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",
|
"basePath": "{{AppSubUrl | JSEscape | Safe}}/api/v1",
|
||||||
"paths": {
|
"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": {
|
"/admin/cron": {
|
||||||
"get": {
|
"get": {
|
||||||
"produces": [
|
"produces": [
|
||||||
|
@ -12700,6 +12826,17 @@
|
||||||
},
|
},
|
||||||
"x-go-package": "code.gitea.io/gitea/modules/structs"
|
"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": {
|
"AddCollaboratorOption": {
|
||||||
"description": "AddCollaboratorOption options when adding a user as a collaborator of a repository",
|
"description": "AddCollaboratorOption options when adding a user as a collaborator of a repository",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
@ -18235,6 +18372,12 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ActivityPub": {
|
||||||
|
"description": "ActivityPub",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/ActivityPub"
|
||||||
|
}
|
||||||
|
},
|
||||||
"AnnotatedTag": {
|
"AnnotatedTag": {
|
||||||
"description": "AnnotatedTag",
|
"description": "AnnotatedTag",
|
||||||
"schema": {
|
"schema": {
|
||||||
|
|
Reference in a new issue