diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 8e5bfeac8ebe0..c4b8913e41b8d 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -2253,6 +2253,23 @@ PATH = ;; ;; Enable/Disable user statistics for nodeinfo if federation is enabled ; SHARE_USER_STATISTICS = true +;; +;; Maximum federation request and response size (MB) +; MAX_SIZE = 4 +;; +;; WARNING: Changing the settings below can break federation. +;; +;; HTTP signature algorithms +; ALGORITHMS = rsa-sha256, rsa-sha512, ed25519 +;; +;; HTTP signature digest algorithm +; DIGEST_ALGORITHM = SHA-256 +;; +;; GET headers for federation requests +; GET_HEADERS = (request-target), Date +;; +;; POST headers for federation requests +; POST_HEADERS = (request-target), Date, Digest ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/docs/content/doc/advanced/config-cheat-sheet.en-us.md b/docs/content/doc/advanced/config-cheat-sheet.en-us.md index bce45d4da0d62..b50e630a83824 100644 --- a/docs/content/doc/advanced/config-cheat-sheet.en-us.md +++ b/docs/content/doc/advanced/config-cheat-sheet.en-us.md @@ -1090,6 +1090,14 @@ Task queue configuration has been moved to `queue.task`. However, the below conf - `ENABLED`: **true**: Enable/Disable federation capabilities - `SHARE_USER_STATISTICS`: **true**: Enable/Disable user statistics for nodeinfo if federation is enabled +- `MAX_SIZE`: **4**: Maximum federation request and response size (MB) + + WARNING: Changing the settings below can break federation. + +- `ALGORITHMS`: **rsa-sha256, rsa-sha512, ed25519**: HTTP signature algorithms +- `DIGEST_ALGORITHM`: **SHA-256**: HTTP signature digest algorithm +- `GET_HEADERS`: **(request-target), Date**: GET headers for federation requests +- `POST_HEADERS`: **(request-target), Date, Digest**: POST headers for federation requests ## Packages (`packages`) diff --git a/go.mod b/go.mod index e06ccfe13d84f..9871d95582035 100644 --- a/go.mod +++ b/go.mod @@ -28,10 +28,12 @@ require ( github.com/ethantkoenig/rupture v1.0.1 github.com/felixge/fgprof v0.9.2 github.com/gliderlabs/ssh v0.3.4 + github.com/go-ap/activitypub v0.0.0-20220615144428-48208c70483b + github.com/go-ap/jsonld v0.0.0-20220615144122-1d862b15410d github.com/go-chi/chi/v5 v5.0.7 github.com/go-chi/cors v1.2.1 github.com/go-enry/go-enry/v2 v2.8.2 - github.com/go-fed/httpsig v1.1.0 + github.com/go-fed/httpsig v1.1.1-0.20201223112313-55836744818e github.com/go-git/go-billy/v5 v5.3.1 github.com/go-git/go-git/v5 v5.4.3-0.20210630082519-b4368b2a2ca4 github.com/go-ldap/ldap/v3 v3.4.3 @@ -83,6 +85,7 @@ require ( github.com/tstranex/u2f v1.0.0 github.com/unrolled/render v1.4.1 github.com/urfave/cli v1.22.9 + github.com/valyala/fastjson v1.6.3 github.com/xanzy/go-gitlab v0.64.0 github.com/yohcop/openid-go v1.0.0 github.com/yuin/goldmark v1.4.12 @@ -107,6 +110,7 @@ require ( require ( cloud.google.com/go v0.99.0 // indirect + git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20200411073322-f0bcc40f0bf2 // indirect github.com/Azure/go-ntlmssp v0.0.0-20211209120228-48547f28849e // indirect github.com/Microsoft/go-winio v0.5.2 // indirect github.com/ProtonMail/go-crypto v0.0.0-20220407094043-a94812496cf5 // indirect @@ -160,6 +164,7 @@ require ( github.com/fsnotify/fsnotify v1.5.4 // indirect github.com/fullstorydev/grpcurl v1.8.1 // indirect github.com/fxamacker/cbor/v2 v2.4.0 // indirect + github.com/go-ap/errors v0.0.0-20220615144307-e8bc4a40ae9f // indirect github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect github.com/go-enry/go-oniguruma v1.2.1 // indirect github.com/go-git/gcfg v1.5.0 // indirect diff --git a/go.sum b/go.sum index 9c99adfbe14cc..ae4d06d1f9916 100644 --- a/go.sum +++ b/go.sum @@ -69,6 +69,8 @@ contrib.go.opencensus.io/exporter/stackdriver v0.13.5/go.mod h1:aXENhDJ1Y4lIg4EU contrib.go.opencensus.io/integrations/ocsql v0.1.4/go.mod h1:8DsSdjz3F+APR+0z0WkU1aRorQCFfRxvqjUUPMbF3fE= contrib.go.opencensus.io/resource v0.1.1/go.mod h1:F361eGI91LCmW1I/Saf+rX0+OFcigGlFvXwEGEnkRLA= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20200411073322-f0bcc40f0bf2 h1:2OrsyJYZp7J6nyAsKi2q1SELYRaIc0aQmcQ/EQqPfk8= +git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20200411073322-f0bcc40f0bf2/go.mod h1:g/V2Hjas6Z1UHUp4yIx6bATpNzJ7DYtD0FG3+xARWxs= gitea.com/go-chi/binding v0.0.0-20220309004920-114340dabecb h1:Yy0Bxzc8R2wxiwXoG/rECGplJUSpXqCsog9PuJFgiHs= gitea.com/go-chi/binding v0.0.0-20220309004920-114340dabecb/go.mod h1:77TZu701zMXWJFvB8gvTbQ92zQ3DQq/H7l5wAEjQRKc= gitea.com/go-chi/cache v0.0.0-20210110083709-82c4c9ce2d5e/go.mod h1:k2V/gPDEtXGjjMGuBJiapffAXTv76H4snSmlJRLUhH0= @@ -460,6 +462,12 @@ github.com/globalsign/mgo v0.0.0-20180905125535-1ca0a4f7cbcb/go.mod h1:xkRDCp4j0 github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= +github.com/go-ap/activitypub v0.0.0-20220615144428-48208c70483b h1:+RjYfEfoZdM3wHFs752dlOpGaoRhwRRyQxjajg08LcQ= +github.com/go-ap/activitypub v0.0.0-20220615144428-48208c70483b/go.mod h1:DE3vvc6Didgfd3k7M1Mos6qMDFNmMrxJmYVMHG9h9Io= +github.com/go-ap/errors v0.0.0-20220615144307-e8bc4a40ae9f h1:kJhGo4NApJP0Lt9lkJnfmuTnRWVFbCynY0kiTxpPUR4= +github.com/go-ap/errors v0.0.0-20220615144307-e8bc4a40ae9f/go.mod h1:KHkKFKZvc05lr79+RGoq/zG8YjWi3+FK60Bxd+mpCew= +github.com/go-ap/jsonld v0.0.0-20220615144122-1d862b15410d h1:Z/oRXMlZHjvjIqDma1FrIGL3iE5YL7MUI0bwYEZ6qbA= +github.com/go-ap/jsonld v0.0.0-20220615144122-1d862b15410d/go.mod h1:jyveZeGw5LaADntW+UEsMjl3IlIwk+DxlYNsbofQkGA= github.com/go-asn1-ber/asn1-ber v1.5.4 h1:vXT6d/FNDiELJnLb6hGNa309LMsrCoYFvpwHDF0+Y1A= github.com/go-asn1-ber/asn1-ber v1.5.4/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-chi/chi/v5 v5.0.1/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= @@ -472,8 +480,8 @@ github.com/go-enry/go-enry/v2 v2.8.2 h1:uiGmC+3K8sVd/6DOe2AOJEOihJdqda83nPyJNtMR github.com/go-enry/go-enry/v2 v2.8.2/go.mod h1:GVzIiAytiS5uT/QiuakK7TF1u4xDab87Y8V5EJRpsIQ= github.com/go-enry/go-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo= github.com/go-enry/go-oniguruma v1.2.1/go.mod h1:bWDhYP+S6xZQgiRL7wlTScFYBe023B6ilRZbCAD5Hf4= -github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= -github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= +github.com/go-fed/httpsig v1.1.1-0.20201223112313-55836744818e h1:oRq/fiirun5HqlEWMLIcDmLpIELlG4iGbd0s8iqgPi8= +github.com/go-fed/httpsig v1.1.1-0.20201223112313-55836744818e/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4= github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E= github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= @@ -1507,6 +1515,8 @@ github.com/urfave/cli v1.22.9 h1:cv3/KhXGBGjEXLC4bH0sLuJ9BewaAbpk5oyMOveu4pw= github.com/urfave/cli v1.22.9/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/urfave/cli/v2 v2.2.0/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fastjson v1.6.3 h1:tAKFnnwmeMGPbwJ7IwxcTPCNr3uIzoIj3/Fh90ra4xc= +github.com/valyala/fastjson v1.6.3/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= github.com/vektah/gqlparser v1.1.2/go.mod h1:1ycwN7Ij5njmMkPPAOaRFY4rET2Enx7IkVv3vaXspKw= github.com/weppos/publicsuffix-go v0.13.1-0.20210123135404-5fd73613514e/go.mod h1:HYux0V0Zi04bHNwOHy4cXJVz/TQjYonnF6aoYhj+3QE= diff --git a/integrations/api_activitypub_person_test.go b/integrations/api_activitypub_person_test.go new file mode 100644 index 0000000000000..4898d5e01d8ef --- /dev/null +++ b/integrations/api_activitypub_person_test.go @@ -0,0 +1,103 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package integrations + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/activitypub" + "code.gitea.io/gitea/modules/setting" + + ap "github.com/go-ap/activitypub" + "github.com/stretchr/testify/assert" +) + +func TestActivityPubPerson(t *testing.T) { + onGiteaRun(t, func(*testing.T, *url.URL) { + setting.Federation.Enabled = true + defer func() { + setting.Federation.Enabled = false + }() + + username := "user2" + req := NewRequestf(t, "GET", fmt.Sprintf("/api/v1/activitypub/user/%s", username)) + resp := MakeRequest(t, req, http.StatusOK) + body := resp.Body.Bytes() + assert.Contains(t, string(body), "@context") + + var person ap.Person + err := person.UnmarshalJSON(body) + assert.NoError(t, err) + + assert.Equal(t, ap.PersonType, person.Type) + assert.Equal(t, username, person.PreferredUsername.String()) + keyID := person.GetID().String() + assert.Regexp(t, fmt.Sprintf("activitypub/user/%s$", username), keyID) + assert.Regexp(t, fmt.Sprintf("activitypub/user/%s/outbox$", username), person.Outbox.GetID().String()) + assert.Regexp(t, fmt.Sprintf("activitypub/user/%s/inbox$", username), person.Inbox.GetID().String()) + + pubKey := person.PublicKey + assert.NotNil(t, pubKey) + publicKeyID := keyID + "#main-key" + assert.Equal(t, pubKey.ID.String(), publicKeyID) + + pubKeyPem := pubKey.PublicKeyPem + assert.NotNil(t, pubKeyPem) + assert.Regexp(t, "^-----BEGIN PUBLIC KEY-----", pubKeyPem) + }) +} + +func TestActivityPubMissingPerson(t *testing.T) { + onGiteaRun(t, func(*testing.T, *url.URL) { + setting.Federation.Enabled = true + defer func() { + setting.Federation.Enabled = false + }() + + req := NewRequestf(t, "GET", "/api/v1/activitypub/user/nonexistentuser") + resp := MakeRequest(t, req, http.StatusNotFound) + assert.Contains(t, resp.Body.String(), "user redirect does not exist") + }) +} + +func TestActivityPubPersonInbox(t *testing.T) { + srv := httptest.NewServer(c) + defer srv.Close() + + onGiteaRun(t, func(*testing.T, *url.URL) { + appURL := setting.AppURL + setting.Federation.Enabled = true + setting.AppURL = srv.URL + defer func() { + setting.Federation.Enabled = false + setting.Database.LogSQL = false + setting.AppURL = appURL + }() + username1 := "user1" + ctx := context.Background() + user1, err := user_model.GetUserByName(ctx, username1) + assert.NoError(t, err) + user1url := fmt.Sprintf("%s/api/v1/activitypub/user/%s#main-key", srv.URL, username1) + c, err := activitypub.NewClient(user1, user1url) + assert.NoError(t, err) + username2 := "user2" + user2inboxurl := fmt.Sprintf("%s/api/v1/activitypub/user/%s/inbox", srv.URL, username2) + + // Signed request succeeds + resp, err := c.Post([]byte{}, user2inboxurl) + assert.NoError(t, err) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + + // Unsigned request fails + req := NewRequest(t, "POST", user2inboxurl) + MakeRequest(t, req, http.StatusInternalServerError) + }) +} diff --git a/integrations/webfinger_test.go b/integrations/webfinger_test.go index 8ba93c3f204b8..07bf58b509fe7 100644 --- a/integrations/webfinger_test.go +++ b/integrations/webfinger_test.go @@ -52,7 +52,7 @@ func TestWebfinger(t *testing.T) { var jrd webfingerJRD DecodeJSON(t, resp, &jrd) assert.Equal(t, "acct:user2@"+appURL.Host, jrd.Subject) - assert.ElementsMatch(t, []string{user.HTMLURL()}, jrd.Aliases) + assert.ElementsMatch(t, []string{user.HTMLURL(), appURL.String() + "api/v1/activitypub/user/" + url.PathEscape(user.Name)}, jrd.Aliases) req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=acct:%s@%s", user.LowerName, "unknown.host")) MakeRequest(t, req, http.StatusBadRequest) diff --git a/models/auth/source.go b/models/auth/source.go index 6f4f5addcb9c6..c152644aa52a1 100644 --- a/models/auth/source.go +++ b/models/auth/source.go @@ -22,14 +22,15 @@ type Type int // Note: new type must append to the end of list to maintain compatibility. const ( - NoType Type = iota - Plain // 1 - LDAP // 2 - SMTP // 3 - PAM // 4 - DLDAP // 5 - OAuth2 // 6 - SSPI // 7 + NoType Type = iota + Plain // 1 + LDAP // 2 + SMTP // 3 + PAM // 4 + DLDAP // 5 + OAuth2 // 6 + SSPI // 7 + Federated // 8 ) // String returns the string name of the LoginType @@ -178,6 +179,11 @@ func (source *Source) IsSSPI() bool { return source.Type == SSPI } +// IsFederated returns true of this source is of the Federated type. +func (source *Source) IsFederated() bool { + return source.Type == Federated +} + // HasTLS returns true of this source supports TLS. func (source *Source) HasTLS() bool { hasTLSer, ok := source.Cfg.(HasTLSer) diff --git a/models/db/name.go b/models/db/name.go index 9c9d18f184748..ae1876a5f1b80 100644 --- a/models/db/name.go +++ b/models/db/name.go @@ -17,7 +17,7 @@ var ( ErrNameEmpty = errors.New("Name is empty") // AlphaDashDotPattern characters prohibited in a user name (anything except A-Za-z0-9_.-) - AlphaDashDotPattern = regexp.MustCompile(`[^\w-\.]`) + AlphaDashDotPattern = regexp.MustCompile(`[^\w-\.@]`) ) // ErrNameReserved represents a "reserved name" error. diff --git a/models/forgefed/repository.go b/models/forgefed/repository.go new file mode 100644 index 0000000000000..8c8ae1d319e75 --- /dev/null +++ b/models/forgefed/repository.go @@ -0,0 +1,71 @@ +// 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 forgefed + +import ( + ap "github.com/go-ap/activitypub" + "github.com/valyala/fastjson" +) + +const ( + RepositoryType ap.ActivityVocabularyType = "Repository" +) + +type Repository struct { + ap.Actor + // Team Collection of actors who have management/push access to the repository + Team ap.Item `jsonld:"team,omitempty"` + // Forks OrderedCollection of repositories that are forks of this repository + Forks ap.Item `jsonld:"forks,omitempty"` +} + +// GetItemByType instantiates a new Repository object if the type matches +// otherwise it defaults to existing activitypub package typer function. +func GetItemByType(typ ap.ActivityVocabularyType) (ap.Item, error) { + if typ == RepositoryType { + return RepositoryNew(""), nil + } + return ap.GetItemByType(typ) +} + +// RepositoryNew initializes a Repository type actor +func RepositoryNew(id ap.ID) *Repository { + a := ap.ActorNew(id, RepositoryType) + o := Repository{Actor: *a} + o.Type = RepositoryType + return &o +} + +func (r Repository) MarshalJSON() ([]byte, error) { + b, err := r.Actor.MarshalJSON() + if len(b) == 0 || err != nil { + return make([]byte, 0), err + } + + b = b[:len(b)-1] + if r.Team != nil { + ap.WriteItemJSONProp(&b, "team", r.Team) + } + if r.Forks != nil { + ap.WriteItemJSONProp(&b, "forks", r.Forks) + } + ap.Write(&b, '}') + return b, nil +} + +func (r *Repository) UnmarshalJSON(data []byte) error { + p := fastjson.Parser{} + val, err := p.ParseBytes(data) + if err != nil { + return err + } + + r.Team = ap.JSONGetItem(val, "team") + r.Forks = ap.JSONGetItem(val, "forks") + + return ap.OnActor(&r.Actor, func(a *ap.Actor) error { + return ap.LoadActor(val, a) + }) +} diff --git a/models/forgefed/repository_test.go b/models/forgefed/repository_test.go new file mode 100644 index 0000000000000..4bf36a66ceab0 --- /dev/null +++ b/models/forgefed/repository_test.go @@ -0,0 +1,167 @@ +package forgefed + +import ( + "encoding/json" + "fmt" + "reflect" + "testing" + + ap "github.com/go-ap/activitypub" +) + +func Test_GetItemByType(t *testing.T) { + type testtt struct { + typ ap.ActivityVocabularyType + want ap.Item + wantErr error + } + tests := map[string]testtt{ + "invalid type": { + typ: ap.ActivityVocabularyType("invalidtype"), + wantErr: fmt.Errorf("empty ActivityStreams type"), // TODO(marius): this error message needs to be improved in go-ap/activitypub + }, + "Repository": { + typ: RepositoryType, + want: new(Repository), + }, + "Person - fall back": { + typ: ap.PersonType, + want: new(ap.Person), + }, + "Question - fall back": { + typ: ap.QuestionType, + want: new(ap.Question), + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + maybeRepository, err := GetItemByType(tt.typ) + if !reflect.DeepEqual(tt.wantErr, err) { + t.Errorf("GetItemByType() error = \"%+v\", wantErr = \"%+v\" when getting Item for type %q", tt.wantErr, err, tt.typ) + } + if reflect.TypeOf(tt.want) != reflect.TypeOf(maybeRepository) { + t.Errorf("Invalid type received %T, expected %T", maybeRepository, tt.want) + } + }) + } +} + +func Test_RepositoryMarshalJSON(t *testing.T) { + type testPair struct { + item Repository + want []byte + wantErr error + } + + tests := map[string]testPair{ + "empty": { + item: Repository{}, + want: nil, + }, + "with ID": { + item: Repository{ + Actor: ap.Actor{ + ID: "https://example.com/1", + }, + Team: nil, + }, + want: []byte(`{"id":"https://example.com/1"}`), + }, + "with Team as IRI": { + item: Repository{ + Team: ap.IRI("https://example.com/1"), + }, + want: []byte(`{"team":"https://example.com/1"}`), + }, + "with Team as IRIs": { + item: Repository{ + Team: ap.ItemCollection{ + ap.IRI("https://example.com/1"), + ap.IRI("https://example.com/2"), + }, + }, + want: []byte(`{"team":["https://example.com/1","https://example.com/2"]}`), + }, + "with Team as Object": { + item: Repository{ + Team: ap.Object{ID: "https://example.com/1"}, + }, + want: []byte(`{"team":{"id":"https://example.com/1"}}`), + }, + "with Team as slice of Objects": { + item: Repository{ + Team: ap.ItemCollection{ + ap.Object{ID: "https://example.com/1"}, + ap.Object{ID: "https://example.com/2"}, + }, + }, + want: []byte(`{"team":[{"id":"https://example.com/1"},{"id":"https://example.com/2"}]}`), + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got, err := tt.item.MarshalJSON() + if (err != nil || tt.wantErr != nil) && tt.wantErr.Error() != err.Error() { + t.Errorf("MarshalJSON() error = \"%v\", wantErr \"%v\"", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("MarshalJSON() got = %q, want %q", got, tt.want) + } + }) + } +} + +func Test_RepositoryUnmarshalJSON(t *testing.T) { + type testPair struct { + data []byte + want *Repository + wantErr error + } + + tests := map[string]testPair{ + "nil": { + data: nil, + wantErr: fmt.Errorf("cannot parse JSON: %w", fmt.Errorf("cannot parse empty string; unparsed tail: %q", "")), + }, + "empty": { + data: []byte{}, + wantErr: fmt.Errorf("cannot parse JSON: %w", fmt.Errorf("cannot parse empty string; unparsed tail: %q", "")), + }, + "with Type": { + data: []byte(`{"type":"Repository"}`), + want: &Repository{ + Actor: ap.Actor{ + Type: RepositoryType, + }, + }, + }, + "with Type and ID": { + data: []byte(`{"id":"https://example.com/1","type":"Repository"}`), + want: &Repository{ + Actor: ap.Actor{ + ID: "https://example.com/1", + Type: RepositoryType, + }, + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got := new(Repository) + err := got.UnmarshalJSON(tt.data) + if (err != nil || tt.wantErr != nil) && tt.wantErr.Error() != err.Error() { + t.Errorf("UnmarshalJSON() error = \"%v\", wantErr \"%v\"", err, tt.wantErr) + return + } + if tt.want != nil && !reflect.DeepEqual(got, tt.want) { + jGot, _ := json.Marshal(got) + jWant, _ := json.Marshal(tt.want) + t.Errorf("UnmarshalJSON() got = %s, want %s", jGot, jWant) + } + }) + } +} diff --git a/models/user/setting_keys.go b/models/user/setting_keys.go index 109b5dd916365..d48ac930527a6 100644 --- a/models/user/setting_keys.go +++ b/models/user/setting_keys.go @@ -9,4 +9,8 @@ const ( SettingsKeyHiddenCommentTypes = "issue.hidden_comment_types" // SettingsKeyDiffWhitespaceBehavior is the setting key for whitespace behavior of diff SettingsKeyDiffWhitespaceBehavior = "diff.whitespace_behaviour" + // UserActivityPubPrivPem is user's private key + UserActivityPubPrivPem = "activitypub.priv_pem" + // UserActivityPubPubPem is user's public key + UserActivityPubPubPem = "activitypub.pub_pem" ) diff --git a/modules/activitypub/client.go b/modules/activitypub/client.go new file mode 100644 index 0000000000000..738b1e47372bb --- /dev/null +++ b/modules/activitypub/client.go @@ -0,0 +1,124 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package activitypub + +import ( + "bytes" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "net/http" + "strings" + "time" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/proxy" + "code.gitea.io/gitea/modules/setting" + + "github.com/go-fed/httpsig" +) + +const ( + // ActivityStreamsContentType const + ActivityStreamsContentType = `application/ld+json; profile="https://www.w3.org/ns/activitystreams"` + httpsigExpirationTime = 60 +) + +// Gets the current time as an RFC 2616 formatted string +// RFC 2616 requires RFC 1123 dates but with GMT instead of UTC +func CurrentTime() string { + return strings.ReplaceAll(time.Now().UTC().Format(time.RFC1123), "UTC", "GMT") +} + +func containsRequiredHTTPHeaders(method string, headers []string) error { + var hasRequestTarget, hasDate, hasDigest bool + for _, header := range headers { + hasRequestTarget = hasRequestTarget || header == httpsig.RequestTarget + hasDate = hasDate || header == "Date" + hasDigest = hasDigest || header == "Digest" + } + if !hasRequestTarget { + return fmt.Errorf("missing http header for %s: %s", method, httpsig.RequestTarget) + } else if !hasDate { + return fmt.Errorf("missing http header for %s: Date", method) + } else if !hasDigest && method != http.MethodGet { + return fmt.Errorf("missing http header for %s: Digest", method) + } + return nil +} + +// Client struct +type Client struct { + client *http.Client + algs []httpsig.Algorithm + digestAlg httpsig.DigestAlgorithm + getHeaders []string + postHeaders []string + priv *rsa.PrivateKey + pubID string +} + +// NewClient function +func NewClient(user *user_model.User, pubID string) (c *Client, err error) { + if err = containsRequiredHTTPHeaders(http.MethodGet, setting.Federation.GetHeaders); err != nil { + return + } else if err = containsRequiredHTTPHeaders(http.MethodPost, setting.Federation.PostHeaders); err != nil { + return + } + + priv, err := GetPrivateKey(user) + if err != nil { + return + } + privPem, _ := pem.Decode([]byte(priv)) + privParsed, err := x509.ParsePKCS1PrivateKey(privPem.Bytes) + if err != nil { + return + } + + c = &Client{ + client: &http.Client{ + Transport: &http.Transport{ + Proxy: proxy.Proxy(), + }, + }, + algs: setting.HttpsigAlgs, + digestAlg: httpsig.DigestAlgorithm(setting.Federation.DigestAlgorithm), + getHeaders: setting.Federation.GetHeaders, + postHeaders: setting.Federation.PostHeaders, + priv: privParsed, + pubID: pubID, + } + return +} + +// NewRequest function +func (c *Client) NewRequest(b []byte, to string) (req *http.Request, err error) { + buf := bytes.NewBuffer(b) + req, err = http.NewRequest(http.MethodPost, to, buf) + if err != nil { + return + } + req.Header.Add("Content-Type", ActivityStreamsContentType) + req.Header.Add("Date", CurrentTime()) + req.Header.Add("User-Agent", "Gitea/"+setting.AppVer) + signer, _, err := httpsig.NewSigner(c.algs, c.digestAlg, c.postHeaders, httpsig.Signature, httpsigExpirationTime) + if err != nil { + return + } + err = signer.SignRequest(c.priv, c.pubID, req, b) + return +} + +// Post function +func (c *Client) Post(b []byte, to string) (resp *http.Response, err error) { + var req *http.Request + if req, err = c.NewRequest(b, to); err != nil { + return + } + resp, err = c.client.Do(req) + return +} diff --git a/modules/activitypub/client_test.go b/modules/activitypub/client_test.go new file mode 100644 index 0000000000000..b93ef5ac988ab --- /dev/null +++ b/modules/activitypub/client_test.go @@ -0,0 +1,49 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package activitypub + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "regexp" + "testing" + + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + + _ "code.gitea.io/gitea/models" // https://discourse.gitea.io/t/testfixtures-could-not-clean-table-access-no-such-table-access/4137/4 + + "github.com/stretchr/testify/assert" +) + +func TestActivityPubSignedPost(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).(*user_model.User) + pubID := "https://example.com/pubID" + c, err := NewClient(user, pubID) + assert.NoError(t, err) + + expected := "BODY" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Regexp(t, regexp.MustCompile("^"+setting.Federation.DigestAlgorithm), r.Header.Get("Digest")) + assert.Contains(t, r.Header.Get("Signature"), pubID) + assert.Equal(t, r.Header.Get("Content-Type"), ActivityStreamsContentType) + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + assert.Equal(t, expected, string(body)) + fmt.Fprintf(w, expected) + })) + defer srv.Close() + + r, err := c.Post([]byte(expected), srv.URL) + assert.NoError(t, err) + defer r.Body.Close() + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + assert.Equal(t, expected, string(body)) +} diff --git a/modules/activitypub/follow.go b/modules/activitypub/follow.go new file mode 100644 index 0000000000000..919b697a8f63c --- /dev/null +++ b/modules/activitypub/follow.go @@ -0,0 +1,46 @@ +// 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" + "strings" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/log" + + ap "github.com/go-ap/activitypub" +) + +func Follow(ctx context.Context, activity ap.Follow) { + actorIRI := activity.Actor.GetID() + objectIRI := activity.Object.GetID() + actorIRISplit := strings.Split(actorIRI.String(), "/") + objectIRISplit := strings.Split(objectIRI.String(), "/") + actorName := actorIRISplit[len(actorIRISplit)-1] + "@" + actorIRISplit[2] + objectName := objectIRISplit[len(objectIRISplit)-1] + + err := FederatedUserNew(actorName, actorIRI) + if err != nil { + log.Warn("Couldn't create new user", err) + } + actorUser, err := user_model.GetUserByName(ctx, actorName) + if err != nil { + log.Warn("Couldn't find actor", err) + } + objectUser, err := user_model.GetUserByName(ctx, objectName) + if err != nil { + log.Warn("Couldn't find object", err) + } + + user_model.FollowUser(actorUser.ID, objectUser.ID) + + accept := ap.AcceptNew(objectIRI, activity) + accept.Actor = ap.Person{ID: objectIRI} + accept.To = ap.ItemCollection{ap.IRI(actorIRI.String() + "/inbox")} + accept.Object = activity + + Send(objectUser, accept) +} diff --git a/modules/activitypub/main_test.go b/modules/activitypub/main_test.go new file mode 100644 index 0000000000000..7fa2b09265cec --- /dev/null +++ b/modules/activitypub/main_test.go @@ -0,0 +1,18 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package activitypub + +import ( + "path/filepath" + "testing" + + "code.gitea.io/gitea/models/unittest" +) + +func TestMain(m *testing.M) { + unittest.MainTest(m, &unittest.TestOptions{ + GiteaRootPath: filepath.Join("..", ".."), + }) +} diff --git a/modules/activitypub/send.go b/modules/activitypub/send.go new file mode 100644 index 0000000000000..96b64d2050828 --- /dev/null +++ b/modules/activitypub/send.go @@ -0,0 +1,59 @@ +// 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/url" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/httplib" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + + ap "github.com/go-ap/activitypub" +) + +func Fetch(iri *url.URL) (b []byte, err error) { + req := httplib.NewRequest(iri.String(), http.MethodGet) + req.Header("Accept", ActivityStreamsContentType) + req.Header("User-Agent", "Gitea/"+setting.AppVer) + resp, err := req.Response() + if err != nil { + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + err = fmt.Errorf("url IRI fetch [%s] failed with status (%d): %s", iri, resp.StatusCode, resp.Status) + return + } + b, err = io.ReadAll(io.LimitReader(resp.Body, setting.Federation.MaxSize)) + return +} + +func Send(user *user_model.User, activity *ap.Activity) { + body, err := activity.MarshalJSON() + if err != nil { + return + } + var jsonmap map[string]interface{} + err = json.Unmarshal(body, &jsonmap) + if err != nil { + return + } + jsonmap["@context"] = "https://www.w3.org/ns/activitystreams" + body, _ = json.Marshal(jsonmap) + + for _, to := range activity.To { + client, _ := NewClient(user, setting.AppURL+"api/v1/activitypub/user/"+user.Name+"#main-key") + resp, _ := client.Post(body, to.GetID().String()) + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, setting.Federation.MaxSize)) + log.Debug(string(respBody)) + } +} diff --git a/modules/activitypub/user.go b/modules/activitypub/user.go new file mode 100644 index 0000000000000..b58f370edc183 --- /dev/null +++ b/modules/activitypub/user.go @@ -0,0 +1,22 @@ +// 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 ( + "code.gitea.io/gitea/models/auth" + user_model "code.gitea.io/gitea/models/user" + + ap "github.com/go-ap/activitypub" +) + +func FederatedUserNew(name string, IRI ap.IRI) error { + user := &user_model.User{ + Name: name, + Email: name, + LoginType: auth.Federated, + Website: IRI.String(), + } + return user_model.CreateUser(user) +} diff --git a/modules/activitypub/user_settings.go b/modules/activitypub/user_settings.go new file mode 100644 index 0000000000000..2144e7b47fb3b --- /dev/null +++ b/modules/activitypub/user_settings.go @@ -0,0 +1,45 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package activitypub + +import ( + user_model "code.gitea.io/gitea/models/user" +) + +// GetKeyPair function returns a user's private and public keys +func GetKeyPair(user *user_model.User) (pub, priv string, err error) { + var settings map[string]*user_model.Setting + settings, err = user_model.GetUserSettings(user.ID, []string{user_model.UserActivityPubPrivPem, user_model.UserActivityPubPubPem}) + if err != nil { + return + } else if len(settings) == 0 { + if priv, pub, err = GenerateKeyPair(); err != nil { + return + } + if err = user_model.SetUserSetting(user.ID, user_model.UserActivityPubPrivPem, priv); err != nil { + return + } + if err = user_model.SetUserSetting(user.ID, user_model.UserActivityPubPubPem, pub); err != nil { + return + } + return + } else { + priv = settings[user_model.UserActivityPubPrivPem].SettingValue + pub = settings[user_model.UserActivityPubPubPem].SettingValue + return + } +} + +// GetPublicKey function returns a user's public key +func GetPublicKey(user *user_model.User) (pub string, err error) { + pub, _, err = GetKeyPair(user) + return +} + +// GetPrivateKey function returns a user's private key +func GetPrivateKey(user *user_model.User) (priv string, err error) { + _, priv, err = GetKeyPair(user) + return +} diff --git a/modules/activitypub/user_settings_test.go b/modules/activitypub/user_settings_test.go new file mode 100644 index 0000000000000..90c6f680f993e --- /dev/null +++ b/modules/activitypub/user_settings_test.go @@ -0,0 +1,29 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package activitypub + +import ( + "testing" + + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + + _ "code.gitea.io/gitea/models" // https://discourse.gitea.io/t/testfixtures-could-not-clean-table-access-no-such-table-access/4137/4 + + "github.com/stretchr/testify/assert" +) + +func TestUserSettings(t *testing.T) { + assert.NoError(t, unittest.PrepareTestDatabase()) + user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).(*user_model.User) + pub, priv, err := GetKeyPair(user1) + assert.NoError(t, err) + pub1, err := GetPublicKey(user1) + assert.NoError(t, err) + assert.Equal(t, pub, pub1) + priv1, err := GetPrivateKey(user1) + assert.NoError(t, err) + assert.Equal(t, priv, priv1) +} diff --git a/modules/setting/federation.go b/modules/setting/federation.go index fd39e5c7c2aff..b06d0a9219097 100644 --- a/modules/setting/federation.go +++ b/modules/setting/federation.go @@ -4,21 +4,49 @@ package setting -import "code.gitea.io/gitea/modules/log" +import ( + "code.gitea.io/gitea/modules/log" + + "github.com/go-fed/httpsig" +) // Federation settings var ( Federation = struct { Enabled bool ShareUserStatistics bool + MaxSize int64 + Algorithms []string + DigestAlgorithm string + GetHeaders []string + PostHeaders []string }{ Enabled: true, ShareUserStatistics: true, + MaxSize: 4, + Algorithms: []string{"rsa-sha256", "rsa-sha512", "ed25519"}, + DigestAlgorithm: "SHA-256", + GetHeaders: []string{"(request-target)", "Date"}, + PostHeaders: []string{"(request-target)", "Date", "Digest"}, } ) +// Constant slice of httpsig algorithm objects +var HttpsigAlgs []httpsig.Algorithm + func newFederationService() { if err := Cfg.Section("federation").MapTo(&Federation); err != nil { log.Fatal("Failed to map Federation settings: %v", err) + } else if !httpsig.IsSupportedDigestAlgorithm(Federation.DigestAlgorithm) { + log.Fatal("unsupported digest algorithm: %s", Federation.DigestAlgorithm) + return + } + + // Get MaxSize in bytes instead of MiB + Federation.MaxSize = 1 << 20 * Federation.MaxSize + + HttpsigAlgs = make([]httpsig.Algorithm, len(Federation.Algorithms)) + for i, alg := range Federation.Algorithms { + HttpsigAlgs[i] = httpsig.Algorithm(alg) } } diff --git a/modules/structs/activitypub.go b/modules/structs/activitypub.go new file mode 100644 index 0000000000000..86681bf9d75aa --- /dev/null +++ b/modules/structs/activitypub.go @@ -0,0 +1,10 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package structs + +// ActivityPub type +type ActivityPub struct { + Context string `json:"@context"` +} diff --git a/routers/api/v1/activitypub/person.go b/routers/api/v1/activitypub/person.go new file mode 100644 index 0000000000000..32d1c2a7064f8 --- /dev/null +++ b/routers/api/v1/activitypub/person.go @@ -0,0 +1,278 @@ +// 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 ( + "io" + "net/http" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/forgefed" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/activitypub" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/routers/api/v1/utils" + + ap "github.com/go-ap/activitypub" +) + +// Person function returns the Person actor for a user +func Person(ctx *context.APIContext) { + // swagger:operation GET /activitypub/user/{username} activitypub activitypubPerson + // --- + // summary: Returns the Person actor for a user + // produces: + // - application/activity+json + // parameters: + // - name: username + // in: path + // description: username of the user + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActivityPub" + + link := strings.TrimSuffix(setting.AppURL, "/") + "/api/v1/activitypub/user/" + ctx.ContextUser.Name + person := ap.PersonNew(ap.IRI(link)) + + person.Name = ap.NaturalLanguageValuesNew() + err := person.Name.Set("en", ap.Content(ctx.ContextUser.FullName)) + if err != nil { + ctx.ServerError("Set Name", err) + return + } + + person.PreferredUsername = ap.NaturalLanguageValuesNew() + err = person.PreferredUsername.Set("en", ap.Content(ctx.ContextUser.Name)) + if err != nil { + ctx.ServerError("Set PreferredUsername", err) + return + } + + person.URL = ap.IRI(ctx.ContextUser.HTMLURL()) + + person.Icon = ap.Image{ + Type: ap.ImageType, + MediaType: "image/png", + URL: ap.IRI(ctx.ContextUser.AvatarLink()), + } + + person.Inbox = ap.IRI(link + "/inbox") + person.Outbox = ap.IRI(link + "/outbox") + + person.Following = ap.IRI(link + "/following") + person.Followers = ap.IRI(link + "/followers") + + person.Liked = ap.IRI(link + "/liked") + + person.PublicKey.ID = ap.IRI(link + "#main-key") + person.PublicKey.Owner = ap.IRI(link) + + publicKeyPem, err := activitypub.GetPublicKey(ctx.ContextUser) + if err != nil { + ctx.ServerError("GetPublicKey", err) + return + } + person.PublicKey.PublicKeyPem = publicKeyPem + + response(ctx, person) +} + +// PersonInbox function handles the incoming data for a user inbox +func PersonInbox(ctx *context.APIContext) { + // swagger:operation POST /activitypub/user/{username}/inbox activitypub activitypubPersonInbox + // --- + // summary: Send to the inbox + // produces: + // - application/activity+json + // parameters: + // - name: username + // in: path + // description: username of the user + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + + body, err := io.ReadAll(io.LimitReader(ctx.Req.Body, setting.Federation.MaxSize)) + if err != nil { + ctx.ServerError("Error reading request body", err) + } + + var activity ap.Activity + activity.UnmarshalJSON(body) + if activity.Type == ap.FollowType { + activitypub.Follow(ctx, activity) + } else { + log.Warn("ActivityStreams type not supported", activity) + ctx.PlainText(http.StatusNotImplemented, "ActivityStreams type not supported") + return + } + + ctx.Status(http.StatusNoContent) +} + +// PersonOutbox function returns the user's Outbox OrderedCollection +func PersonOutbox(ctx *context.APIContext) { + // swagger:operation GET /activitypub/user/{username}/outbox activitypub activitypubPersonOutbox + // --- + // summary: Returns the Outbox OrderedCollection + // produces: + // - application/activity+json + // parameters: + // - name: username + // in: path + // description: username of the user + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActivityPub" + + link := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ctx.Req.URL.EscapedPath(), "/") + + feed, err := models.GetFeeds(ctx, models.GetFeedsOptions{ + RequestedUser: ctx.ContextUser, + Actor: ctx.ContextUser, + IncludePrivate: false, + OnlyPerformedBy: true, + IncludeDeleted: false, + Date: ctx.FormString("date"), + }) + if err != nil { + ctx.ServerError("Couldn't fetch outbox", err) + } + + outbox := ap.OrderedCollectionNew(ap.IRI(link)) + for _, action := range feed { + /*if action.OpType == ExampleType { + activity := ap.ExampleNew() + outbox.OrderedItems.Append(activity) + }*/ + log.Debug(action.Content) + } + outbox.TotalItems = uint(len(outbox.OrderedItems)) + + response(ctx, outbox) +} + +// PersonFollowing function returns the user's Following Collection +func PersonFollowing(ctx *context.APIContext) { + // swagger:operation GET /activitypub/user/{username}/following activitypub activitypubPersonFollowing + // --- + // summary: Returns the Following Collection + // produces: + // - application/activity+json + // parameters: + // - name: username + // in: path + // description: username of the user + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActivityPub" + + link := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ctx.Req.URL.EscapedPath(), "/") + + users, err := user_model.GetUserFollowing(ctx.ContextUser, utils.GetListOptions(ctx)) + if err != nil { + ctx.ServerError("GetUserFollowing", err) + return + } + + following := ap.OrderedCollectionNew(ap.IRI(link)) + following.TotalItems = uint(len(users)) + + for _, user := range users { + // TODO: handle non-Federated users + person := ap.PersonNew(ap.IRI(user.Website)) + following.OrderedItems.Append(person) + } + + response(ctx, following) +} + +// PersonFollowers function returns the user's Followers Collection +func PersonFollowers(ctx *context.APIContext) { + // swagger:operation GET /activitypub/user/{username}/followers activitypub activitypubPersonFollowers + // --- + // summary: Returns the Followers Collection + // produces: + // - application/activity+json + // parameters: + // - name: username + // in: path + // description: username of the user + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActivityPub" + + link := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ctx.Req.URL.EscapedPath(), "/") + + users, err := user_model.GetUserFollowers(ctx.ContextUser, utils.GetListOptions(ctx)) + if err != nil { + ctx.ServerError("GetUserFollowers", err) + return + } + + followers := ap.OrderedCollectionNew(ap.IRI(link)) + followers.TotalItems = uint(len(users)) + + for _, user := range users { + person := ap.PersonNew(ap.IRI(user.Website)) + followers.OrderedItems.Append(person) + } + + response(ctx, followers) +} + +// PersonLiked function returns the user's Liked Collection +func PersonLiked(ctx *context.APIContext) { + // swagger:operation GET /activitypub/user/{username}/followers activitypub activitypubPersonLiked + // --- + // summary: Returns the Liked Collection + // produces: + // - application/activity+json + // parameters: + // - name: username + // in: path + // description: username of the user + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActivityPub" + + link := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ctx.Req.URL.EscapedPath(), "/") + + repos, count, err := repo_model.SearchRepository(&repo_model.SearchRepoOptions{ + Actor: ctx.Doer, + Private: ctx.IsSigned, + StarredByID: ctx.ContextUser.ID, + }) + if err != nil { + ctx.ServerError("GetUserStarred", err) + return + } + + liked := ap.OrderedCollectionNew(ap.IRI(link)) + liked.TotalItems = uint(count) + + for _, repo := range repos { + repo := forgefed.RepositoryNew(ap.IRI(strings.TrimSuffix(setting.AppURL, "/") + "/api/v1/activitypub/repo/" + repo.OwnerName + "/" + repo.Name)) + liked.OrderedItems.Append(repo) + } + + response(ctx, liked) +} diff --git a/routers/api/v1/activitypub/repo.go b/routers/api/v1/activitypub/repo.go new file mode 100644 index 0000000000000..a766c41ef1ed0 --- /dev/null +++ b/routers/api/v1/activitypub/repo.go @@ -0,0 +1,198 @@ +// 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 ( + "io" + "net/http" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/forgefed" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/routers/api/v1/utils" + + ap "github.com/go-ap/activitypub" +) + +// Repo function +func Repo(ctx *context.APIContext) { + // swagger:operation GET /activitypub/repo/{username}/{reponame} activitypub activitypubRepo + // --- + // summary: Returns the repository + // produces: + // - application/activity+json + // parameters: + // - name: username + // in: path + // description: username of the user + // type: string + // required: true + // - name: reponame + // in: path + // description: name of the repository + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActivityPub" + + link := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ctx.Req.URL.EscapedPath(), "/") + repo := forgefed.RepositoryNew(ap.IRI(link)) + + repo.Name = ap.NaturalLanguageValuesNew() + err := repo.Name.Set("en", ap.Content(ctx.Repo.Repository.Name)) + if err != nil { + ctx.ServerError("Set Name", err) + return + } + + repo.AttributedTo = ap.IRI(strings.TrimSuffix(link, "/"+ctx.Repo.Repository.Name)) + + repo.Summary = ap.NaturalLanguageValuesNew() + err = repo.Summary.Set("en", ap.Content(ctx.Repo.Repository.Description)) + if err != nil { + ctx.ServerError("Set Description", err) + return + } + + repo.Inbox = ap.IRI(link + "/inbox") + repo.Outbox = ap.IRI(link + "/outbox") + repo.Followers = ap.IRI(link + "/followers") + repo.Team = ap.IRI(link + "/team") + + response(ctx, repo) +} + +// RepoInbox function +func RepoInbox(ctx *context.APIContext) { + // swagger:operation POST /activitypub/repo/{username}/{reponame}/inbox activitypub activitypubRepoInbox + // --- + // summary: Send to the inbox + // produces: + // - application/activity+json + // parameters: + // - name: username + // in: path + // description: username of the user + // type: string + // required: true + // - name: reponame + // in: path + // description: name of the repository + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + + body, err := io.ReadAll(ctx.Req.Body) + if err != nil { + ctx.ServerError("Error reading request body", err) + } + + var activity ap.Activity + activity.UnmarshalJSON(body) + if activity.Type == ap.FollowType { + // activitypub.Follow(ctx, activity) + } else { + log.Warn("ActivityStreams type not supported", activity) + } + + ctx.Status(http.StatusNoContent) +} + +// RepoOutbox function +func RepoOutbox(ctx *context.APIContext) { + // swagger:operation GET /activitypub/repo/{username}/outbox activitypub activitypubPersonOutbox + // --- + // summary: Returns the outbox + // produces: + // - application/activity+json + // parameters: + // - name: username + // in: path + // description: username of the user + // type: string + // required: true + // - name: reponame + // in: path + // description: name of the repository + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActivityPub" + + link := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ctx.Req.URL.EscapedPath(), "/") + + feed, err := models.GetFeeds(ctx, models.GetFeedsOptions{ + RequestedUser: ctx.ContextUser, + Actor: ctx.ContextUser, + IncludePrivate: false, + OnlyPerformedBy: true, + IncludeDeleted: false, + Date: ctx.FormString("date"), + }) + if err != nil { + ctx.ServerError("Couldn't fetch outbox", err) + } + + outbox := ap.OrderedCollectionNew(ap.IRI(link)) + for _, action := range feed { + /*if action.OpType == ExampleType { + activity := ap.ExampleNew() + outbox.OrderedItems.Append(activity) + }*/ + log.Debug(action.Content) + } + outbox.TotalItems = uint(len(outbox.OrderedItems)) + + response(ctx, outbox) +} + +// RepoFollowers function +func RepoFollowers(ctx *context.APIContext) { + // swagger:operation GET /activitypub/repo/{username}/{reponame}/followers activitypub activitypubRepoFollowers + // --- + // summary: Returns the followers collection + // produces: + // - application/activity+json + // parameters: + // - name: username + // in: path + // description: username of the user + // type: string + // required: true + // - name: reponame + // in: path + // description: name of the repository + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActivityPub" + + link := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ctx.Req.URL.EscapedPath(), "/") + + users, err := user_model.GetUserFollowers(ctx.ContextUser, utils.GetListOptions(ctx)) + if err != nil { + ctx.ServerError("GetUserFollowers", err) + return + } + + followers := ap.OrderedCollectionNew(ap.IRI(link)) + followers.TotalItems = uint(len(users)) + + for _, user := range users { + person := ap.PersonNew(ap.IRI(user.Website)) + followers.OrderedItems.Append(person) + } + + response(ctx, followers) +} diff --git a/routers/api/v1/activitypub/reqsignature.go b/routers/api/v1/activitypub/reqsignature.go new file mode 100644 index 0000000000000..f7a2e9b7cdc36 --- /dev/null +++ b/routers/api/v1/activitypub/reqsignature.go @@ -0,0 +1,82 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package activitypub + +import ( + "crypto" + "crypto/x509" + "encoding/pem" + "fmt" + "net/http" + "net/url" + + "code.gitea.io/gitea/modules/activitypub" + gitea_context "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/setting" + + ap "github.com/go-ap/activitypub" + "github.com/go-fed/httpsig" +) + +func getPublicKeyFromResponse(b []byte, keyID *url.URL) (p crypto.PublicKey, err error) { + person := ap.PersonNew(ap.IRI(keyID.String())) + err = person.UnmarshalJSON(b) + if err != nil { + err = fmt.Errorf("ActivityStreams type cannot be converted to one known to have publicKey property: %v", err) + return + } + pubKey := person.PublicKey + if pubKey.ID.String() != keyID.String() { + err = fmt.Errorf("cannot find publicKey with id: %s in %s", keyID, string(b)) + return + } + pubKeyPem := pubKey.PublicKeyPem + block, _ := pem.Decode([]byte(pubKeyPem)) + if block == nil || block.Type != "PUBLIC KEY" { + err = fmt.Errorf("could not decode publicKeyPem to PUBLIC KEY pem block type") + return + } + p, err = x509.ParsePKIXPublicKey(block.Bytes) + return +} + +func 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 := activitypub.Fetch(idIRI) + if err != nil { + return + } + pubKey, err := getPublicKeyFromResponse(b, idIRI) + if err != nil { + return + } + // 3. Verify the other actor's key + algo := httpsig.Algorithm(setting.Federation.Algorithms[0]) + authenticated = v.Verify(pubKey, algo) == nil + return +} + +// ReqHTTPSignature function +func ReqHTTPSignature() func(ctx *gitea_context.APIContext) { + return func(ctx *gitea_context.APIContext) { + if authenticated, err := verifyHTTPSignatures(ctx); err != nil { + ctx.ServerError("verifyHttpSignatures", err) + } else if !authenticated { + ctx.Error(http.StatusForbidden, "reqSignature", "request signature verification failed") + } + } +} diff --git a/routers/api/v1/activitypub/response.go b/routers/api/v1/activitypub/response.go new file mode 100644 index 0000000000000..fb1465352384a --- /dev/null +++ b/routers/api/v1/activitypub/response.go @@ -0,0 +1,29 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package activitypub + +import ( + "net/http" + + "code.gitea.io/gitea/modules/activitypub" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/log" + + ap "github.com/go-ap/activitypub" + "github.com/go-ap/jsonld" +) + +func response(ctx *context.APIContext, v interface{}) { + binary, err := jsonld.WithContext(jsonld.IRI(ap.ActivityBaseURI), jsonld.IRI(ap.SecurityContextURI)).Marshal(v) + if err != nil { + ctx.ServerError("Marshal", err) + return + } + ctx.Resp.Header().Add("Content-Type", activitypub.ActivityStreamsContentType) + ctx.Resp.WriteHeader(http.StatusOK) + if _, err = ctx.Resp.Write(binary); err != nil { + log.Error("write to resp err: %v", err) + } +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 03f7a57d5c861..59d5cd231cbff 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -81,6 +81,7 @@ import ( "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/activitypub" "code.gitea.io/gitea/routers/api/v1/admin" "code.gitea.io/gitea/routers/api/v1/misc" "code.gitea.io/gitea/routers/api/v1/notify" @@ -643,6 +644,22 @@ func Routes() *web.Route { m.Get("/version", misc.Version) if setting.Federation.Enabled { m.Get("/nodeinfo", misc.NodeInfo) + m.Group("/activitypub", func() { + m.Group("/user/{username}", func() { + m.Get("", activitypub.Person) + m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox) + m.Get("/outbox", activitypub.PersonOutbox) + m.Get("/following", activitypub.PersonFollowing) + m.Get("/followers", activitypub.PersonFollowers) + m.Get("/liked", activitypub.PersonLiked) + }, context_service.UserAssignmentAPI()) + m.Group("/repo/{username}/{reponame}", func() { + m.Get("", activitypub.Repo) + m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.RepoInbox) + m.Get("/outbox", activitypub.RepoOutbox) + m.Get("/followers", activitypub.RepoFollowers) + }, repoAssignment()) + }) } m.Get("/signing-key.gpg", misc.SigningKey) m.Post("/markdown", bind(api.MarkdownOption{}), misc.Markdown) diff --git a/routers/api/v1/swagger/activitypub.go b/routers/api/v1/swagger/activitypub.go new file mode 100644 index 0000000000000..afc0c05057d60 --- /dev/null +++ b/routers/api/v1/swagger/activitypub.go @@ -0,0 +1,16 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package swagger + +import ( + api "code.gitea.io/gitea/modules/structs" +) + +// ActivityPub +// swagger:response ActivityPub +type swaggerResponseActivityPub struct { + // in:body + Body api.ActivityPub `json:"body"` +} diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go index dd5804cd42d5c..bf2660881f8fa 100644 --- a/routers/web/user/profile.go +++ b/routers/web/user/profile.go @@ -36,6 +36,12 @@ func Profile(ctx *context.Context) { return } + if strings.Contains(ctx.ContextUser.Name, "@") { + ctx.Resp.Header().Add("Location", ctx.ContextUser.Website) + ctx.Resp.WriteHeader(http.StatusTemporaryRedirect) + return + } + if ctx.ContextUser.IsOrganization() { org.Home(ctx) return diff --git a/routers/web/webfinger.go b/routers/web/webfinger.go index 8402967867e59..b52c1879a9967 100644 --- a/routers/web/webfinger.go +++ b/routers/web/webfinger.go @@ -29,6 +29,7 @@ type webfingerLink struct { Rel string `json:"rel,omitempty"` Type string `json:"type,omitempty"` Href string `json:"href,omitempty"` + Template string `json:"template,omitempty"` Titles map[string]string `json:"titles,omitempty"` Properties map[string]interface{} `json:"properties,omitempty"` } @@ -86,6 +87,7 @@ func WebfingerQuery(ctx *context.Context) { aliases := []string{ u.HTMLURL(), + appURL.String() + "api/v1/activitypub/user/" + url.PathEscape(u.Name), } if !u.KeepEmailPrivate { aliases = append(aliases, fmt.Sprintf("mailto:%s", u.Email)) @@ -101,8 +103,18 @@ func WebfingerQuery(ctx *context.Context) { Rel: "http://webfinger.net/rel/avatar", Href: u.AvatarLink(), }, + { + Rel: "self", + Type: "application/activity+json", + Href: appURL.String() + "api/v1/activitypub/user/" + url.PathEscape(u.Name), + }, + { + Rel: "http://ostatus.org/schema/1.0/subscribe", + Template: appURL.String() + "api/v1/authorize_interaction?uri={uri}", + }, } + ctx.Resp.Header().Add("Access-Control-Allow-Origin", "*") ctx.JSON(http.StatusOK, &webfingerJRD{ Subject: fmt.Sprintf("acct:%s@%s", url.QueryEscape(u.Name), appURL.Host), Aliases: aliases, diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 4da8b12af4bb1..6ad45012e8947 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -23,6 +23,268 @@ }, "basePath": "{{AppSubUrl | JSEscape | Safe}}/api/v1", "paths": { + "/activitypub/repo/{username}/outbox": { + "get": { + "produces": [ + "application/activity+json" + ], + "tags": [ + "activitypub" + ], + "summary": "Returns the outbox", + "operationId": "activitypubPersonOutbox", + "parameters": [ + { + "type": "string", + "description": "username of the user", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repository", + "name": "reponame", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ActivityPub" + } + } + } + }, + "/activitypub/repo/{username}/{reponame}": { + "get": { + "produces": [ + "application/activity+json" + ], + "tags": [ + "activitypub" + ], + "summary": "Returns the repository", + "operationId": "activitypubRepo", + "parameters": [ + { + "type": "string", + "description": "username of the user", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repository", + "name": "reponame", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ActivityPub" + } + } + } + }, + "/activitypub/repo/{username}/{reponame}/followers": { + "get": { + "produces": [ + "application/activity+json" + ], + "tags": [ + "activitypub" + ], + "summary": "Returns the followers collection", + "operationId": "activitypubRepoFollowers", + "parameters": [ + { + "type": "string", + "description": "username of the user", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repository", + "name": "reponame", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ActivityPub" + } + } + } + }, + "/activitypub/repo/{username}/{reponame}/inbox": { + "post": { + "produces": [ + "application/activity+json" + ], + "tags": [ + "activitypub" + ], + "summary": "Send to the inbox", + "operationId": "activitypubRepoInbox", + "parameters": [ + { + "type": "string", + "description": "username of the user", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repository", + "name": "reponame", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + } + } + } + }, + "/activitypub/user/{username}": { + "get": { + "produces": [ + "application/activity+json" + ], + "tags": [ + "activitypub" + ], + "summary": "Returns the Person actor for a user", + "operationId": "activitypubPerson", + "parameters": [ + { + "type": "string", + "description": "username of the user", + "name": "username", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ActivityPub" + } + } + } + }, + "/activitypub/user/{username}/followers": { + "get": { + "produces": [ + "application/activity+json" + ], + "tags": [ + "activitypub" + ], + "summary": "Returns the Liked Collection", + "operationId": "activitypubPersonLiked", + "parameters": [ + { + "type": "string", + "description": "username of the user", + "name": "username", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ActivityPub" + } + } + } + }, + "/activitypub/user/{username}/following": { + "get": { + "produces": [ + "application/activity+json" + ], + "tags": [ + "activitypub" + ], + "summary": "Returns the Following Collection", + "operationId": "activitypubPersonFollowing", + "parameters": [ + { + "type": "string", + "description": "username of the user", + "name": "username", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ActivityPub" + } + } + } + }, + "/activitypub/user/{username}/inbox": { + "post": { + "produces": [ + "application/activity+json" + ], + "tags": [ + "activitypub" + ], + "summary": "Send to the inbox", + "operationId": "activitypubPersonInbox", + "parameters": [ + { + "type": "string", + "description": "username of the user", + "name": "username", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + } + } + } + }, + "/activitypub/user/{username}/outbox": { + "get": { + "produces": [ + "application/activity+json" + ], + "tags": [ + "activitypub" + ], + "summary": "Returns the Outbox OrderedCollection", + "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": [ @@ -13113,6 +13375,17 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "ActivityPub": { + "description": "ActivityPub type", + "type": "object", + "properties": { + "@context": { + "type": "string", + "x-go-name": "Context" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "AddCollaboratorOption": { "description": "AddCollaboratorOption options when adding a user as a collaborator of a repository", "type": "object", @@ -18794,6 +19067,12 @@ } } }, + "ActivityPub": { + "description": "ActivityPub", + "schema": { + "$ref": "#/definitions/ActivityPub" + } + }, "AnnotatedTag": { "description": "AnnotatedTag", "schema": {