diff --git a/FEDERATION.md b/FEDERATION.md new file mode 100644 index 0000000000000..86e74892a6fa6 --- /dev/null +++ b/FEDERATION.md @@ -0,0 +1,47 @@ +# Federation + +*This describes Gitea's future federation capabilities, not what it can do currently.* + +Gitea is federated using [ActivityPub](https://www.w3.org/TR/activitypub/) and the [ForgeFed extension](https://forgefed.org/) so you can interact with users and repositories from other instances as if they were on your own instance. By using the standardized ActivityPub protocol, users on any fediverse software such as [Mastodon](https://joinmastodon.org/) can follow Gitea users, star repositories, receive activity updates, and comment on issues. + +C2S ActivityPub is not supported because Gitea already has an existing API. + +## Following + +You can use any fediverse software to follow a Gitea user. Gitea will automatically accept follow requests. The usernames of remote users are displayed as `username@instance.com`. To follow a remote user, click follow on their profile page, and a pop-up box will appear for you to type in your instance. You are redirected to your own instance, where the remote user is fetched and rendered, and you can now follow them. + +When following a Gitea user, you will receive updates when they star a repo, create, fork, or make a private repo public, or follow a user. If you are using Mastodon or Pleroma, these will show up in your feed. + +## Starring + +You can star repositories on another instance. The full name of a remote repository is `username@instance.com/reponame`. Similar to following, a pop-up box appears for you to type in your instance, and you are redirected to your own instance, where the remote repository is fetched and rendered. + +## Organizations + +You can add users from other instances to organizations. An organization has a name and an instance, so its full name would look like `orgname@instance.com`. This indicates that the organization data resides on `instance.com`. To prevent synchronization errors, this data is only synchronized one-way to other instances. + +## Collaborators + +You can add users from other instances as collaborators. As mentioned previously, a repository has full name `username@instance.com/reponame`, which indicates that the repository data resides on `instance.com`. Each collaborator's instance has a copy of the repository, but to prevent synchronization errors, the copy at `instance.com` is the main copy and it is synchronized one-way to all other instances. When a collaborator tries to modify their copy of the repository, the modification is first sent to the main copy at `instance.com` and then synchronized back to their instance. + +## Issues + +You can create an issue on a remote repository. Your instance can also render a remote issue that you created so you can edit it or comment on it. + +## Forks + +When forking a remote repository, the fork is created on your instance, not the remote instance. + +## Pull requests + +When opening a pull request to a remote repository, the pull request can be rendered on your instance. Federated pull requests use the AGit-flow. + +## Comments + +You can comment on an issue or pull request using any fediverse software. The issue and existing comments are rendered on your instance. + +## Migrations + +If you change your username or the name of a repository, Gitea handles this similarly to how Mastodon does. Gitea will send a `Move` activity to your followers and update your actor to point to the new actor and the new actor to point to the old actor. + +Changing your instance or a repository's instance is handled in a similar way, but additionally, the data to be migrated between instances. diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index b59ceee4f1db9..ef247fa7885cd 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -2308,7 +2308,7 @@ ROUTER = console ;SHARE_USER_STATISTICS = true ;; ;; Maximum federation request and response size (MB) -;MAX_SIZE = 4 +;MAX_SIZE = 8 ;; ;; WARNING: Changing the settings below can break federation. ;; diff --git a/go.mod b/go.mod index 408249880c752..5d38ff0481b51 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,7 @@ require ( github.com/fsnotify/fsnotify v1.5.4 github.com/gliderlabs/ssh v0.3.5 github.com/go-ap/activitypub v0.0.0-20220917143152-e4e7018838c0 - github.com/go-ap/jsonld v0.0.0-20220917142617-76bf51585778 + github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73 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.3 @@ -87,6 +87,7 @@ require ( github.com/tstranex/u2f v1.0.0 github.com/unrolled/render v1.5.0 github.com/urfave/cli v1.22.10 + github.com/valyala/fastjson v1.6.3 github.com/xanzy/go-gitlab v0.73.1 github.com/yohcop/openid-go v1.0.0 github.com/yuin/goldmark v1.5.2 @@ -168,7 +169,7 @@ require ( github.com/form3tech-oss/jwt-go v3.2.3+incompatible // 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-20220917143055-4283ea5dae18 // indirect + github.com/go-ap/errors v0.0.0-20221115052505-8aaa26f930b4 // 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 @@ -264,7 +265,6 @@ require ( github.com/toqueteos/webbrowser v1.2.0 // indirect github.com/ulikunitz/xz v0.5.10 // indirect github.com/unknwon/com v1.0.1 // indirect - github.com/valyala/fastjson v1.6.3 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.2 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect @@ -302,6 +302,8 @@ replace github.com/shurcooL/vfsgen => github.com/lunny/vfsgen v0.0.0-20220105142 replace github.com/satori/go.uuid v1.2.0 => github.com/gofrs/uuid v4.2.0+incompatible +replace github.com/go-ap/activitypub => gitea.com/xy/activitypub v0.0.0-20221126171442-81405e14ea3b + exclude github.com/gofrs/uuid v3.2.0+incompatible exclude github.com/gofrs/uuid v4.0.0+incompatible diff --git a/go.sum b/go.sum index 65841b8ec334a..56eb524a817a5 100644 --- a/go.sum +++ b/go.sum @@ -96,6 +96,8 @@ gitea.com/lunny/levelqueue v0.4.2-0.20220729054728-f020868cc2f7 h1:Zc3RQWC2xOVgl gitea.com/lunny/levelqueue v0.4.2-0.20220729054728-f020868cc2f7/go.mod h1:HBqmLbz56JWpfEGG0prskAV97ATNRoj5LDmPicD22hU= gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s= gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU= +gitea.com/xy/activitypub v0.0.0-20221126171442-81405e14ea3b h1:z5zmwZVoKEu2c3+lGiLlTDxQZpcKlZoWz4wjCtcyfxU= +gitea.com/xy/activitypub v0.0.0-20221126171442-81405e14ea3b/go.mod h1:1jG7QyKCGx/FO63p/xWO0h9ytVSJmkjcQSYPj6zWpGs= gitee.com/travelliu/dm v1.8.11192/go.mod h1:DHTzyhCrM843x9VdKVbZ+GKXGRbKM2sJ4LxihRxShkE= github.com/42wim/sshsig v0.0.0-20211121163825-841cf5bbc121 h1:r3qt8PCHnfjOv9PN3H+XXKmDA1dfFMIN1AislhlA/ps= github.com/42wim/sshsig v0.0.0-20211121163825-841cf5bbc121/go.mod h1:Ock8XgA7pvULhIaHGAk/cDnRfNrF9Jey81nPcc403iU= @@ -471,12 +473,10 @@ github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY= github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4= 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-20220917143152-e4e7018838c0 h1:EUMB0x7u3de/ikGBtXiLxaJbmxgiqiAcM4yjW4whApM= -github.com/go-ap/activitypub v0.0.0-20220917143152-e4e7018838c0/go.mod h1:OX9ajs2vU4UauC/DlghS/8M468Kn79r+y9kB6j7LuGM= -github.com/go-ap/errors v0.0.0-20220917143055-4283ea5dae18 h1:A48SbkWKEciiJMbbcPzaRj9aizPUABzXFvCM3LtGGf8= -github.com/go-ap/errors v0.0.0-20220917143055-4283ea5dae18/go.mod h1:dd3ZgjjloBsKPDpqA2kf2VWhF0A1eKUItOBh0/QcDWI= -github.com/go-ap/jsonld v0.0.0-20220917142617-76bf51585778 h1:0tV3i8tE1NghMC4rXZXfD39KUbkKgIyLTsvOEmMOPCQ= -github.com/go-ap/jsonld v0.0.0-20220917142617-76bf51585778/go.mod h1:jyveZeGw5LaADntW+UEsMjl3IlIwk+DxlYNsbofQkGA= +github.com/go-ap/errors v0.0.0-20221115052505-8aaa26f930b4 h1:oySiT87Q2cd0o5O8er2zyjiRcTQA0KuOgw1N9+RQqG0= +github.com/go-ap/errors v0.0.0-20221115052505-8aaa26f930b4/go.mod h1:SaTNjEEkp0q+w3pUS1ccyEL/lUrHteORlDq/e21mCc8= +github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73 h1:GMKIYXyXPGIp+hYiWOhfqK4A023HdgisDT4YGgf99mw= +github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73/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= diff --git a/models/activities/action.go b/models/activities/action.go index cad3263c2d790..b4fda2241900a 100644 --- a/models/activities/action.go +++ b/models/activities/action.go @@ -329,14 +329,15 @@ func (a *Action) GetIssueContent() string { // GetFeedsOptions options for retrieving feeds type GetFeedsOptions struct { db.ListOptions - RequestedUser *user_model.User // the user we want activity for - RequestedTeam *organization.Team // the team we want activity for - RequestedRepo *repo_model.Repository // the repo we want activity for - Actor *user_model.User // the user viewing the activity - IncludePrivate bool // include private actions - OnlyPerformedBy bool // only actions performed by requested user - IncludeDeleted bool // include deleted actions - Date string // the day we want activity for: YYYY-MM-DD + RequestedUser *user_model.User // the user we want activity for + RequestedTeam *organization.Team // the team we want activity for + RequestedRepo *repo_model.Repository // the repo we want activity for + RequestedActionType ActionType // the type of activity we want + Actor *user_model.User // the user viewing the activity + IncludePrivate bool // include private actions + OnlyPerformedBy bool // only actions performed by requested user + IncludeDeleted bool // include deleted actions + Date string // the day we want activity for: YYYY-MM-DD } // GetFeeds returns actions according to the provided options @@ -448,6 +449,10 @@ func activityQueryCondition(opts GetFeedsOptions) (builder.Cond, error) { } } + if opts.RequestedActionType != 0 { + cond = cond.And(builder.Eq{"`action`.op_type": opts.RequestedActionType}) + } + return cond, nil } diff --git a/models/activities/action_test.go b/models/activities/action_test.go index ac2a3043a6a27..d97c0c33c4fb6 100644 --- a/models/activities/action_test.go +++ b/models/activities/action_test.go @@ -10,7 +10,7 @@ import ( activities_model "code.gitea.io/gitea/models/activities" "code.gitea.io/gitea/models/db" - issue_model "code.gitea.io/gitea/models/issues" + issues_model "code.gitea.io/gitea/models/issues" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" @@ -31,7 +31,7 @@ func TestAction_GetRepoLink(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) - comment := unittest.AssertExistsAndLoadBean(t, &issue_model.Comment{ID: 2}) + comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2}) action := &activities_model.Action{RepoID: repo.ID, CommentID: comment.ID} setting.AppSubURL = "/suburl" expected := path.Join(setting.AppSubURL, owner.Name, repo.Name) diff --git a/models/auth/source.go b/models/auth/source.go index f8be5398aef67..cb45887059213 100644 --- a/models/auth/source.go +++ b/models/auth/source.go @@ -23,14 +23,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 @@ -179,6 +180,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/issues/comment.go b/models/issues/comment.go index 6877991a9399c..fe692bd588537 100644 --- a/models/issues/comment.go +++ b/models/issues/comment.go @@ -26,6 +26,7 @@ import ( "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/references" + "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" @@ -1549,3 +1550,18 @@ func FixCommentTypeLabelWithOutsideLabels() (int64, error) { return res.RowsAffected() } + +func (c *Comment) GetIRI() string { + err := c.LoadIssue() + if err != nil { + return "" + } + err = c.Issue.LoadRepo(db.DefaultContext) + if err != nil { + return "" + } + if strings.Contains(c.Issue.Repo.OwnerName, "@") { + return c.OldTitle + } + return setting.AppURL + "api/v1/activitypub/note/" + c.Issue.Repo.OwnerName + "/" + c.Issue.Repo.Name + "/" + strconv.FormatInt(c.Issue.Index, 10) + "/" + strconv.FormatInt(c.ID, 10) +} diff --git a/models/issues/issue.go b/models/issues/issue.go index ca48f425f2c40..7ae0494a3a466 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -27,6 +27,7 @@ import ( "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/references" + "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/util" @@ -2471,3 +2472,14 @@ func DeleteOrphanedIssues() error { } return nil } + +func (issue *Issue) GetIRI() string { + err := issue.LoadRepo(db.DefaultContext) + if err != nil { + log.Error(fmt.Sprintf("loadRepo: %v", err)) + } + if strings.Contains(issue.Repo.OwnerName, "@") { + return issue.OriginalAuthor + } + return setting.AppURL + "api/v1/activitypub/ticket/" + issue.Repo.OwnerName + "/" + issue.Repo.Name + "/" + strconv.FormatInt(issue.Index, 10) +} diff --git a/models/repo/repo.go b/models/repo/repo.go index 77e0367a5a5c7..2bb9d0cc447eb 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -802,3 +802,10 @@ func FixNullArchivedRepository() (int64, error) { IsArchived: false, }) } + +func (r *Repository) GetIRI() string { + if strings.Contains(r.OwnerName, "@") { + return r.OriginalURL + } + return setting.AppURL + "api/v1/activitypub/repo/" + r.OwnerName + "/" + r.Name +} diff --git a/models/user/avatar.go b/models/user/avatar.go index f73ac56c5e4ce..89285a040b214 100644 --- a/models/user/avatar.go +++ b/models/user/avatar.go @@ -105,6 +105,15 @@ func (u *User) AvatarLink() string { return link } +// AvatarFullLinkWithSize returns the full avatar link with size and http host +func (u *User) AvatarFullLinkWithSize(size int) string { + link := u.AvatarLinkWithSize(size) + if !strings.HasPrefix(link, "//") && !strings.Contains(link, "://") { + return setting.AppURL + strings.TrimPrefix(link, setting.AppSubURL+"/") + } + return link +} + // IsUploadAvatarChanged returns true if the current user's avatar would be changed with the provided data func (u *User) IsUploadAvatarChanged(data []byte) bool { if !u.UseCustomAvatar || len(u.Avatar) == 0 { diff --git a/models/user/user.go b/models/user/user.go index 84e2c4a9cc6ab..0949fe342417f 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -1339,3 +1339,10 @@ func GetOrderByName() string { } return "name" } + +func (u *User) GetIRI() string { + if u.LoginType == auth.Federated { + return u.LoginName + } + return setting.AppURL + "api/v1/activitypub/user/" + u.Name +} diff --git a/modules/forgefed/branch.go b/modules/forgefed/branch.go new file mode 100644 index 0000000000000..29ec54981bd83 --- /dev/null +++ b/modules/forgefed/branch.go @@ -0,0 +1,99 @@ +// 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 ( + "reflect" + "unsafe" + + ap "github.com/go-ap/activitypub" + "github.com/valyala/fastjson" +) + +const ( + BranchType ap.ActivityVocabularyType = "Branch" +) + +type Branch struct { + ap.Object + // Ref the unique identifier of the branch within the repo + Ref ap.Item `jsonld:"ref,omitempty"` +} + +// BranchNew initializes a Branch type Object +func BranchNew() *Branch { + a := ap.ObjectNew(BranchType) + o := Branch{Object: *a} + return &o +} + +func (b Branch) MarshalJSON() ([]byte, error) { + bin, err := b.Object.MarshalJSON() + if len(bin) == 0 || err != nil { + return nil, err + } + + bin = bin[:len(bin)-1] + if b.Ref != nil { + ap.JSONWriteItemJSONProp(&bin, "ref", b.Ref) + } + ap.JSONWrite(&bin, '}') + return bin, nil +} + +func JSONLoadBranch(val *fastjson.Value, b *Branch) error { + if err := ap.OnObject(&b.Object, func(o *ap.Object) error { + return ap.JSONLoadObject(val, o) + }); err != nil { + return err + } + + b.Ref = ap.JSONGetItem(val, "ref") + return nil +} + +func (b *Branch) UnmarshalJSON(data []byte) error { + p := fastjson.Parser{} + val, err := p.ParseBytes(data) + if err != nil { + return err + } + return JSONLoadBranch(val, b) +} + +// ToBranch tries to convert the it Item to a Branch object. +func ToBranch(it ap.Item) (*Branch, error) { + switch i := it.(type) { + case *Branch: + return i, nil + case Branch: + return &i, nil + case *ap.Object: + return (*Branch)(unsafe.Pointer(i)), nil + case ap.Object: + return (*Branch)(unsafe.Pointer(&i)), nil + default: + // NOTE(marius): this is an ugly way of dealing with the interface conversion error: types from different scopes + typ := reflect.TypeOf(new(Branch)) + if i, ok := reflect.ValueOf(it).Convert(typ).Interface().(*Branch); ok { + return i, nil + } + } + return nil, ap.ErrorInvalidType[ap.Object](it) +} + +type withBranchFn func(*Branch) error + +// OnBranch calls function fn on it Item if it can be asserted to type *Branch +func OnBranch(it ap.Item, fn withBranchFn) error { + if it == nil { + return nil + } + ob, err := ToBranch(it) + if err != nil { + return err + } + return fn(ob) +} diff --git a/modules/forgefed/commit.go b/modules/forgefed/commit.go new file mode 100644 index 0000000000000..f7337486fc8af --- /dev/null +++ b/modules/forgefed/commit.go @@ -0,0 +1,106 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package forgefed + +import ( + "reflect" + "time" + "unsafe" + + ap "github.com/go-ap/activitypub" + "github.com/valyala/fastjson" +) + +const ( + CommitType ap.ActivityVocabularyType = "Commit" +) + +type Commit struct { + ap.Object + // Created time at which the commit was written by its author + Created time.Time `jsonld:"created,omitempty"` + // Committed time at which the commit was committed by its committer + Committed time.Time `jsonld:"committed,omitempty"` +} + +// CommitNew initializes a Commit type Object +func CommitNew() *Commit { + a := ap.ObjectNew(CommitType) + o := Commit{Object: *a} + return &o +} + +func (c Commit) MarshalJSON() ([]byte, error) { + b, err := c.Object.MarshalJSON() + if len(b) == 0 || err != nil { + return nil, err + } + + b = b[:len(b)-1] + if !c.Created.IsZero() { + ap.JSONWriteTimeJSONProp(&b, "created", c.Created) + } + if !c.Committed.IsZero() { + ap.JSONWriteTimeJSONProp(&b, "committed", c.Committed) + } + ap.JSONWrite(&b, '}') + return b, nil +} + +func JSONLoadCommit(val *fastjson.Value, c *Commit) error { + if err := ap.OnObject(&c.Object, func(o *ap.Object) error { + return ap.JSONLoadObject(val, o) + }); err != nil { + return err + } + + c.Created = ap.JSONGetTime(val, "created") + c.Committed = ap.JSONGetTime(val, "committed") + return nil +} + +func (c *Commit) UnmarshalJSON(data []byte) error { + p := fastjson.Parser{} + val, err := p.ParseBytes(data) + if err != nil { + return err + } + return JSONLoadCommit(val, c) +} + +// ToCommit tries to convert the it Item to a Commit object. +func ToCommit(it ap.Item) (*Commit, error) { + switch i := it.(type) { + case *Commit: + return i, nil + case Commit: + return &i, nil + case *ap.Object: + return (*Commit)(unsafe.Pointer(i)), nil + case ap.Object: + return (*Commit)(unsafe.Pointer(&i)), nil + default: + // NOTE(marius): this is an ugly way of dealing with the interface conversion error: types from different scopes + typ := reflect.TypeOf(new(Commit)) + if i, ok := reflect.ValueOf(it).Convert(typ).Interface().(*Commit); ok { + return i, nil + } + } + return nil, ap.ErrorInvalidType[ap.Object](it) +} + +type withCommitFn func(*Commit) error + +// OnCommit calls function fn on it Item if it can be asserted to type *Commit +func OnCommit(it ap.Item, fn withCommitFn) error { + if it == nil { + return nil + } + ob, err := ToCommit(it) + if err != nil { + return err + } + return fn(ob) +} diff --git a/modules/forgefed/forgefed.go b/modules/forgefed/forgefed.go new file mode 100644 index 0000000000000..61962319f5db7 --- /dev/null +++ b/modules/forgefed/forgefed.go @@ -0,0 +1,98 @@ +// 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 ForgeFedNamespaceURI = "https://forgefed.org/ns" + +// GetItemByType instantiates a new ForgeFed object if the type matches +// otherwise it defaults to existing activitypub package typer function. +func GetItemByType(typ ap.ActivityVocabularyType) (ap.Item, error) { + switch typ { + case CommitType: + return CommitNew(), nil + case BranchType: + return BranchNew(), nil + case RepositoryType: + return RepositoryNew(""), nil + case PushType: + return PushNew(), nil + case TicketType: + return TicketNew(), nil + } + return ap.GetItemByType(typ) +} + +// JSONUnmarshalerFn is the function that will load the data from a fastjson.Value into an Item +// that the go-ap/activitypub package doesn't know about. +func JSONUnmarshalerFn(typ ap.ActivityVocabularyType, val *fastjson.Value, i ap.Item) error { + switch typ { + case CommitType: + return OnCommit(i, func(c *Commit) error { + return JSONLoadCommit(val, c) + }) + case BranchType: + return OnBranch(i, func(b *Branch) error { + return JSONLoadBranch(val, b) + }) + case RepositoryType: + return OnRepository(i, func(r *Repository) error { + return JSONLoadRepository(val, r) + }) + case PushType: + return OnPush(i, func(p *Push) error { + return JSONLoadPush(val, p) + }) + case TicketType: + return OnTicket(i, func(t *Ticket) error { + return JSONLoadTicket(val, t) + }) + } + return nil +} + +// NotEmpty is the function that checks if an object is empty +func NotEmpty(i ap.Item) bool { + if ap.IsNil(i) { + return false + } + switch i.GetType() { + case CommitType: + c, err := ToCommit(i) + if err != nil { + return false + } + return ap.NotEmpty(c.Object) + case BranchType: + b, err := ToBranch(i) + if err != nil { + return false + } + return ap.NotEmpty(b.Object) + case RepositoryType: + r, err := ToRepository(i) + if err != nil { + return false + } + return ap.NotEmpty(r.Actor) + case PushType: + p, err := ToPush(i) + if err != nil { + return false + } + return ap.NotEmpty(p.Object) + case TicketType: + t, err := ToTicket(i) + if err != nil { + return false + } + return ap.NotEmpty(t.Object) + } + return ap.NotEmpty(i) +} diff --git a/modules/forgefed/push.go b/modules/forgefed/push.go new file mode 100644 index 0000000000000..299ed9c2a99fe --- /dev/null +++ b/modules/forgefed/push.go @@ -0,0 +1,111 @@ +// 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 ( + "reflect" + "unsafe" + + ap "github.com/go-ap/activitypub" + "github.com/valyala/fastjson" +) + +const ( + PushType ap.ActivityVocabularyType = "Push" +) + +type Push struct { + ap.Object + // Target the specific repo history tip onto which the commits were added + Target ap.Item `jsonld:"target,omitempty"` + // HashBefore hash before adding the new commits + HashBefore ap.Item `jsonld:"hashBefore,omitempty"` + // HashAfter hash before adding the new commits + HashAfter ap.Item `jsonld:"hashAfter,omitempty"` +} + +// PushNew initializes a Push type Object +func PushNew() *Push { + a := ap.ObjectNew(PushType) + o := Push{Object: *a} + return &o +} + +func (p Push) MarshalJSON() ([]byte, error) { + b, err := p.Object.MarshalJSON() + if len(b) == 0 || err != nil { + return nil, err + } + + b = b[:len(b)-1] + if p.Target != nil { + ap.JSONWriteItemJSONProp(&b, "target", p.Target) + } + if p.HashBefore != nil { + ap.JSONWriteItemJSONProp(&b, "hashBefore", p.HashBefore) + } + if p.HashAfter != nil { + ap.JSONWriteItemJSONProp(&b, "hashAfter", p.HashAfter) + } + ap.JSONWrite(&b, '}') + return b, nil +} + +func JSONLoadPush(val *fastjson.Value, p *Push) error { + if err := ap.OnObject(&p.Object, func(o *ap.Object) error { + return ap.JSONLoadObject(val, o) + }); err != nil { + return err + } + + p.Target = ap.JSONGetItem(val, "target") + p.HashBefore = ap.JSONGetItem(val, "hashBefore") + p.HashAfter = ap.JSONGetItem(val, "hashAfter") + return nil +} + +func (p *Push) UnmarshalJSON(data []byte) error { + par := fastjson.Parser{} + val, err := par.ParseBytes(data) + if err != nil { + return err + } + return JSONLoadPush(val, p) +} + +// ToPush tries to convert the it Item to a Push object. +func ToPush(it ap.Item) (*Push, error) { + switch i := it.(type) { + case *Push: + return i, nil + case Push: + return &i, nil + case *ap.Object: + return (*Push)(unsafe.Pointer(i)), nil + case ap.Object: + return (*Push)(unsafe.Pointer(&i)), nil + default: + // NOTE(marius): this is an ugly way of dealing with the interface conversion error: types from different scopes + typ := reflect.TypeOf(new(Push)) + if i, ok := reflect.ValueOf(it).Convert(typ).Interface().(*Push); ok { + return i, nil + } + } + return nil, ap.ErrorInvalidType[ap.Object](it) +} + +type withPushFn func(*Push) error + +// OnPush calls function fn on it Item if it can be asserted to type *Push +func OnPush(it ap.Item, fn withPushFn) error { + if it == nil { + return nil + } + ob, err := ToPush(it) + if err != nil { + return err + } + return fn(ob) +} diff --git a/modules/forgefed/repository.go b/modules/forgefed/repository.go new file mode 100644 index 0000000000000..3d1319d27d2d5 --- /dev/null +++ b/modules/forgefed/repository.go @@ -0,0 +1,112 @@ +// 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 ( + "reflect" + "unsafe" + + 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"` + // ForkedFrom Identifies the repository which this repository was created as a fork + ForkedFrom ap.Item `jsonld:"forkedFrom,omitempty"` +} + +// RepositoryNew initializes a Repository type actor +func RepositoryNew(id ap.ID) *Repository { + a := ap.ActorNew(id, RepositoryType) + a.Type = RepositoryType + o := Repository{Actor: *a} + return &o +} + +func (r Repository) MarshalJSON() ([]byte, error) { + b, err := r.Actor.MarshalJSON() + if len(b) == 0 || err != nil { + return nil, err + } + + b = b[:len(b)-1] + if r.Team != nil { + ap.JSONWriteItemJSONProp(&b, "team", r.Team) + } + if r.Forks != nil { + ap.JSONWriteItemJSONProp(&b, "forks", r.Forks) + } + if r.ForkedFrom != nil { + ap.JSONWriteItemJSONProp(&b, "forkedFrom", r.ForkedFrom) + } + ap.JSONWrite(&b, '}') + return b, nil +} + +func JSONLoadRepository(val *fastjson.Value, r *Repository) error { + if err := ap.OnActor(&r.Actor, func(a *ap.Actor) error { + return ap.JSONLoadActor(val, a) + }); err != nil { + return err + } + + r.Team = ap.JSONGetItem(val, "team") + r.Forks = ap.JSONGetItem(val, "forks") + r.ForkedFrom = ap.JSONGetItem(val, "forkedFrom") + return nil +} + +func (r *Repository) UnmarshalJSON(data []byte) error { + p := fastjson.Parser{} + val, err := p.ParseBytes(data) + if err != nil { + return err + } + return JSONLoadRepository(val, r) +} + +// ToRepository tries to convert the it Item to a Repository Actor. +func ToRepository(it ap.Item) (*Repository, error) { + switch i := it.(type) { + case *Repository: + return i, nil + case Repository: + return &i, nil + case *ap.Actor: + return (*Repository)(unsafe.Pointer(i)), nil + case ap.Actor: + return (*Repository)(unsafe.Pointer(&i)), nil + default: + // NOTE(marius): this is an ugly way of dealing with the interface conversion error: types from different scopes + typ := reflect.TypeOf(new(Repository)) + if i, ok := reflect.ValueOf(it).Convert(typ).Interface().(*Repository); ok { + return i, nil + } + } + return nil, ap.ErrorInvalidType[ap.Actor](it) +} + +type withRepositoryFn func(*Repository) error + +// OnRepository calls function fn on it Item if it can be asserted to type *Repository +func OnRepository(it ap.Item, fn withRepositoryFn) error { + if it == nil { + return nil + } + ob, err := ToRepository(it) + if err != nil { + return err + } + return fn(ob) +} diff --git a/modules/forgefed/repository_test.go b/modules/forgefed/repository_test.go new file mode 100644 index 0000000000000..935240bb343d1 --- /dev/null +++ b/modules/forgefed/repository_test.go @@ -0,0 +1,184 @@ +// 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 ( + "fmt" + "reflect" + "testing" + + "code.gitea.io/gitea/modules/json" + + 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"), + Actor: ap.Actor{ + ID: "https://example.com/1", + }, + }, + want: []byte(`{"id":"https://example.com/1","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"), + }, + Actor: ap.Actor{ + ID: "https://example.com/1", + }, + }, + want: []byte(`{"id":"https://example.com/1","team":["https://example.com/1","https://example.com/2"]}`), + }, + "with Team as Object": { + item: Repository{ + Team: ap.Object{ID: "https://example.com/1"}, + Actor: ap.Actor{ + ID: "https://example.com/1", + }, + }, + want: []byte(`{"id":"https://example.com/1","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"}, + }, + Actor: ap.Actor{ + ID: "https://example.com/1", + }, + }, + want: []byte(`{"id":"https://example.com/1","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/modules/forgefed/ticket.go b/modules/forgefed/ticket.go new file mode 100644 index 0000000000000..99836b61c3938 --- /dev/null +++ b/modules/forgefed/ticket.go @@ -0,0 +1,134 @@ +// 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 ( + "reflect" + "time" + "unsafe" + + ap "github.com/go-ap/activitypub" + "github.com/valyala/fastjson" +) + +const ( + TicketType ap.ActivityVocabularyType = "Ticket" +) + +type Ticket struct { + ap.Object + // Dependants Collection of Tickets which depend on this ticket + Dependants ap.ItemCollection `jsonld:"dependants,omitempty"` + // Dependencies Collection of Tickets on which this ticket depends + Dependencies ap.ItemCollection `jsonld:"dependencies,omitempty"` + // IsResolved Whether the work on this ticket is done + IsResolved bool `jsonld:"isResolved,omitempty"` + // ResolvedBy If the work on this ticket is done, who marked the ticket as resolved, or which activity did so + ResolvedBy ap.Item `jsonld:"resolvedBy,omitempty"` + // Resolved When the ticket has been marked as resolved + Resolved time.Time `jsonld:"resolved,omitempty"` + // Origin The head branch if this ticket is a pull request + Origin ap.Item `jsonld:"origin,omitempty"` + // Target The base branch if this ticket is a pull request + Target ap.Item `jsonld:"target,omitempty"` +} + +// TicketNew initializes a Ticket type Object +func TicketNew() *Ticket { + a := ap.ObjectNew(TicketType) + o := Ticket{Object: *a} + return &o +} + +func (t Ticket) MarshalJSON() ([]byte, error) { + b, err := t.Object.MarshalJSON() + if len(b) == 0 || err != nil { + return nil, err + } + + b = b[:len(b)-1] + if t.Dependants != nil { + ap.JSONWriteItemCollectionJSONProp(&b, "dependants", t.Dependants) + } + if t.Dependencies != nil { + ap.JSONWriteItemCollectionJSONProp(&b, "dependencies", t.Dependencies) + } + ap.JSONWriteBoolJSONProp(&b, "isResolved", t.IsResolved) + if t.ResolvedBy != nil { + ap.JSONWriteItemJSONProp(&b, "resolvedBy", t.ResolvedBy) + } + if !t.Resolved.IsZero() { + ap.JSONWriteTimeJSONProp(&b, "resolved", t.Resolved) + } + if t.Origin != nil { + ap.JSONWriteItemJSONProp(&b, "origin", t.Origin) + } + if t.Target != nil { + ap.JSONWriteItemJSONProp(&b, "target", t.Target) + } + ap.JSONWrite(&b, '}') + return b, nil +} + +func JSONLoadTicket(val *fastjson.Value, t *Ticket) error { + if err := ap.OnObject(&t.Object, func(o *ap.Object) error { + return ap.JSONLoadObject(val, o) + }); err != nil { + return err + } + + t.Dependants = ap.JSONGetItems(val, "dependants") + t.Dependencies = ap.JSONGetItems(val, "dependencies") + t.IsResolved = ap.JSONGetBoolean(val, "isResolved") + t.ResolvedBy = ap.JSONGetItem(val, "resolvedBy") + t.Resolved = ap.JSONGetTime(val, "resolved") + t.Origin = ap.JSONGetItem(val, "origin") + t.Target = ap.JSONGetItem(val, "target") + return nil +} + +func (t *Ticket) UnmarshalJSON(data []byte) error { + p := fastjson.Parser{} + val, err := p.ParseBytes(data) + if err != nil { + return err + } + return JSONLoadTicket(val, t) +} + +// ToTicket tries to convert the it Item to a Ticket object. +func ToTicket(it ap.Item) (*Ticket, error) { + switch i := it.(type) { + case *Ticket: + return i, nil + case Ticket: + return &i, nil + case *ap.Object: + return (*Ticket)(unsafe.Pointer(i)), nil + case ap.Object: + return (*Ticket)(unsafe.Pointer(&i)), nil + default: + // NOTE(marius): this is an ugly way of dealing with the interface conversion error: types from different scopes + typ := reflect.TypeOf(new(Ticket)) + if i, ok := reflect.ValueOf(it).Convert(typ).Interface().(*Ticket); ok { + return i, nil + } + } + return nil, ap.ErrorInvalidType[ap.Object](it) +} + +type withTicketFn func(*Ticket) error + +// OnTicket calls function fn on it Item if it can be asserted to type *Ticket +func OnTicket(it ap.Item, fn withTicketFn) error { + if it == nil { + return nil + } + ob, err := ToTicket(it) + if err != nil { + return err + } + return fn(ob) +} diff --git a/modules/setting/federation.go b/modules/setting/federation.go index 583d9a6e2bcee..89bbc1cf0b0d7 100644 --- a/modules/setting/federation.go +++ b/modules/setting/federation.go @@ -23,7 +23,7 @@ var ( }{ Enabled: false, ShareUserStatistics: true, - MaxSize: 4, + MaxSize: 8, Algorithms: []string{"rsa-sha256", "rsa-sha512", "ed25519"}, DigestAlgorithm: "SHA-256", GetHeaders: []string{"(request-target)", "Date"}, diff --git a/modules/validation/helpers.go b/modules/validation/helpers.go index 8e49c7855e5dd..e554a629c3282 100644 --- a/modules/validation/helpers.go +++ b/modules/validation/helpers.go @@ -93,7 +93,7 @@ func IsValidExternalTrackerURLFormat(uri string) bool { } var ( - validUsernamePattern = regexp.MustCompile(`^[\da-zA-Z][-.\w]*$`) + validUsernamePattern = regexp.MustCompile(`^[\da-zA-Z][-.\w@]*$`) invalidUsernamePattern = regexp.MustCompile(`[-._]{2,}|[-._]$`) // No consecutive or trailing non-alphanumeric chars ) diff --git a/routers/api/v1/activitypub/authorize_interaction.go b/routers/api/v1/activitypub/authorize_interaction.go new file mode 100644 index 0000000000000..683393d607008 --- /dev/null +++ b/routers/api/v1/activitypub/authorize_interaction.go @@ -0,0 +1,97 @@ +// 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" + "strconv" + + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/forgefed" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/activitypub" + + ap "github.com/go-ap/activitypub" +) + +// Fetch and load a remote object +func AuthorizeInteraction(ctx *context.Context) { + uri, err := url.Parse(ctx.Req.URL.Query().Get("uri")) + if err != nil { + ctx.ServerError("Parse URI", err) + return + } + resp, err := activitypub.Fetch(uri) + if err != nil { + ctx.ServerError("Fetch", err) + return + } + + ap.ItemTyperFunc = forgefed.GetItemByType + ap.JSONItemUnmarshal = forgefed.JSONUnmarshalerFn + ap.NotEmptyChecker = forgefed.NotEmpty + object, err := ap.UnmarshalJSON(resp) + if err != nil { + ctx.ServerError("UnmarshalJSON", err) + return + } + + switch object.GetType() { + case ap.PersonType: + // Federated user + person, err := ap.ToActor(object) + if err != nil { + ctx.ServerError("ToActor", err) + return + } + err = createPerson(ctx, person) + if err != nil { + ctx.ServerError("FederatedUserNew", err) + return + } + name, err := activitypub.PersonIRIToName(object.GetLink()) + if err != nil { + ctx.ServerError("PersonIRIToName", err) + return + } + ctx.Redirect(setting.AppURL + name) + case forgefed.RepositoryType: + // Federated repository + err = forgefed.OnRepository(object, func(r *forgefed.Repository) error { + return createRepository(ctx, r) + }) + if err != nil { + ctx.ServerError("FederatedRepoNew", err) + return + } + username, reponame, err := activitypub.RepositoryIRIToName(object.GetLink()) + if err != nil { + ctx.ServerError("RepositoryIRIToName", err) + return + } + ctx.Redirect(setting.AppURL + username + "/" + reponame) + case forgefed.TicketType: + // Federated issue or pull request + err = forgefed.OnTicket(object, func(t *forgefed.Ticket) error { + return createTicket(ctx, t) + }) + if err != nil { + ctx.ServerError("ReceiveIssue", err) + return + } + username, reponame, idx, err := activitypub.TicketIRIToName(object.GetLink()) + if err != nil { + ctx.ServerError("TicketIRIToName", err) + return + } + ctx.Redirect(setting.AppURL + username + "/" + reponame + "/issues/" + strconv.FormatInt(idx, 10)) + default: + ctx.ServerError("Not implemented", err) + return + } + + ctx.Status(http.StatusOK) +} diff --git a/routers/api/v1/activitypub/create.go b/routers/api/v1/activitypub/create.go new file mode 100644 index 0000000000000..38ee898e3b308 --- /dev/null +++ b/routers/api/v1/activitypub/create.go @@ -0,0 +1,319 @@ +// 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" + "strconv" + "strings" + + "code.gitea.io/gitea/models/auth" + issues_model "code.gitea.io/gitea/models/issues" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/forgefed" + repo_module "code.gitea.io/gitea/modules/repository" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/activitypub" + issue_service "code.gitea.io/gitea/services/issue" + pull_service "code.gitea.io/gitea/services/pull" + repo_service "code.gitea.io/gitea/services/repository" + user_service "code.gitea.io/gitea/services/user" + + ap "github.com/go-ap/activitypub" +) + +// Create a new federated user from a Person object +func createPerson(ctx context.Context, person *ap.Person) error { + name, err := activitypub.PersonIRIToName(person.GetLink()) + if err != nil { + return err + } + + exists, err := user_model.IsUserExist(ctx, 0, name) + if err != nil { + return err + } + if exists { + return nil + } + + var email string + if person.Location != nil { + email = person.Location.GetLink().String() + } else { + // This might not even work + email = strings.ReplaceAll(name, "@", "+") + "@" + setting.Service.NoReplyAddress + } + + if person.PublicKey.PublicKeyPem == "" { + return errors.New("person public key not found") + } + + user := &user_model.User{ + Name: name, + FullName: person.Name.String(), // May not exist!! + Email: email, + LoginType: auth.Federated, + LoginName: person.GetLink().String(), + EmailNotificationsPreference: user_model.EmailNotificationsDisabled, + } + err = user_model.CreateUser(user) + if err != nil { + return err + } + + if person.Icon != nil { + icon, err := ap.ToObject(person.Icon) + if err != nil { + return err + } + iconURL, err := icon.URL.GetLink().URL() + if err != nil { + return err + } + + body, err := activitypub.Fetch(iconURL) + if err != nil { + return err + } + + err = user_service.UploadAvatar(user, body) + if err != nil { + return err + } + } + + err = user_model.SetUserSetting(user.ID, user_model.UserActivityPubPrivPem, "") + if err != nil { + return err + } + return user_model.SetUserSetting(user.ID, user_model.UserActivityPubPubPem, person.PublicKey.PublicKeyPem) +} + +func createPersonFromIRI(ctx context.Context, personIRI ap.IRI) error { + ownerURL, err := url.Parse(personIRI.String()) + if err != nil { + return err + } + // Fetch person object + resp, err := activitypub.Fetch(ownerURL) + if err != nil { + return err + } + + // Parse person object + ap.ItemTyperFunc = forgefed.GetItemByType + ap.JSONItemUnmarshal = forgefed.JSONUnmarshalerFn + ap.NotEmptyChecker = forgefed.NotEmpty + object, err := ap.UnmarshalJSON(resp) + if err != nil { + return err + } + + // Create federated user + person, err := ap.ToActor(object) + if err != nil { + return err + } + return createPerson(ctx, person) +} + +// Create a new federated repo from a Repository object +func createRepository(ctx context.Context, repository *forgefed.Repository) error { + err := createPersonFromIRI(ctx, repository.AttributedTo.GetLink()) + if err != nil { + return err + } + user, err := activitypub.PersonIRIToUser(ctx, repository.AttributedTo.GetLink()) + if err != nil { + return err + } + + // Check if repo exists + _, err = repo_model.GetRepositoryByOwnerAndNameCtx(ctx, user.Name, repository.Name.String()) + if err == nil { + return nil + } + + repo, err := repo_service.CreateRepository(user, user, repo_module.CreateRepoOptions{ + Name: repository.Name.String(), + OriginalURL: repository.GetLink().String(), + }) + if err != nil { + return err + } + + if repository.ForkedFrom != nil { + repo.IsFork = true + forkedFrom, err := activitypub.RepositoryIRIToRepository(ctx, repository.ForkedFrom.GetLink()) + if err != nil { + return err + } + repo.ForkID = forkedFrom.ID + } + return nil +} + +func createRepositoryFromIRI(ctx context.Context, repoIRI ap.IRI) error { + repoURL, err := url.Parse(repoIRI.String()) + if err != nil { + return err + } + // Fetch repository object + resp, err := activitypub.Fetch(repoURL) + if err != nil { + return err + } + + // Parse repository object + ap.ItemTyperFunc = forgefed.GetItemByType + ap.JSONItemUnmarshal = forgefed.JSONUnmarshalerFn + ap.NotEmptyChecker = forgefed.NotEmpty + object, err := ap.UnmarshalJSON(resp) + if err != nil { + return err + } + + // Create federated repo + return forgefed.OnRepository(object, func(r *forgefed.Repository) error { + return createRepository(ctx, r) + }) +} + +// Create a ticket +func createTicket(ctx context.Context, ticket *forgefed.Ticket) error { + if ticket.Origin != nil && ticket.Target != nil { + return createPullRequest(ctx, ticket) + } + return createIssue(ctx, ticket) +} + +// Create an issue +func createIssue(ctx context.Context, ticket *forgefed.Ticket) error { + err := createRepositoryFromIRI(ctx, ticket.Context.GetLink()) + if err != nil { + return err + } + + // Construct issue + user, err := activitypub.PersonIRIToUser(ctx, ap.IRI(ticket.AttributedTo.GetLink().String())) + if err != nil { + return err + } + repo, err := activitypub.RepositoryIRIToRepository(ctx, ap.IRI(ticket.Context.GetLink().String())) + if err != nil { + return err + } + idx, err := strconv.ParseInt(ticket.Name.String()[1:], 10, 64) + if err != nil { + return err + } + issue := &issues_model.Issue{ + Index: idx, // This doesn't seem to work? + RepoID: repo.ID, + Repo: repo, + Title: ticket.Summary.String(), + PosterID: user.ID, + Poster: user, + Content: ticket.Content.String(), + OriginalAuthor: ticket.GetLink().String(), // Create new database field to store IRI? + IsClosed: ticket.IsResolved, + } + return issue_service.NewIssue(repo, issue, nil, nil, nil) +} + +// Create a pull request +func createPullRequest(ctx context.Context, ticket *forgefed.Ticket) error { + err := createRepositoryFromIRI(ctx, ticket.Context.GetLink()) + if err != nil { + return err + } + + user, err := activitypub.PersonIRIToUser(ctx, ticket.AttributedTo.GetLink()) + if err != nil { + return err + } + + // Extract origin and target repos + originUsername, originReponame, originBranch, err := activitypub.BranchIRIToName(ticket.Origin.GetLink()) + if err != nil { + return err + } + originRepo, err := repo_model.GetRepositoryByOwnerAndName(originUsername, originReponame) + if err != nil { + return err + } + targetUsername, targetReponame, targetBranch, err := activitypub.BranchIRIToName(ticket.Target.GetLink()) + if err != nil { + return err + } + targetRepo, err := repo_model.GetRepositoryByOwnerAndName(targetUsername, targetReponame) + if err != nil { + return err + } + + idx, err := strconv.ParseInt(ticket.Name.String()[1:], 10, 64) + if err != nil { + return err + } + prIssue := &issues_model.Issue{ + Index: idx, + RepoID: targetRepo.ID, + Title: ticket.Summary.String(), + PosterID: user.ID, + Poster: user, + IsPull: true, + Content: ticket.Content.String(), + IsClosed: ticket.IsResolved, + } + pr := &issues_model.PullRequest{ + HeadRepoID: originRepo.ID, + BaseRepoID: targetRepo.ID, + HeadBranch: originBranch, + BaseBranch: targetBranch, + HeadRepo: originRepo, + BaseRepo: targetRepo, + MergeBase: "", + Type: issues_model.PullRequestGitea, + } + return pull_service.NewPullRequest(ctx, targetRepo, prIssue, []int64{}, []string{}, pr, []int64{}) +} + +// Create a comment +func createComment(ctx context.Context, note *ap.Note) error { + err := createPersonFromIRI(ctx, note.AttributedTo.GetLink()) + if err != nil { + return err + } + + user, err := activitypub.PersonIRIToUser(ctx, note.AttributedTo.GetLink()) + if err != nil { + return err + } + + username, reponame, idx, err := activitypub.TicketIRIToName(note.Context.GetLink()) + if err != nil { + return err + } + repo, err := repo_model.GetRepositoryByOwnerAndNameCtx(ctx, username, reponame) + if err != nil { + return err + } + issue, err := issues_model.GetIssueByIndex(repo.ID, idx) + if err != nil { + return err + } + _, err = issues_model.CreateCommentCtx(ctx, &issues_model.CreateCommentOptions{ + Doer: user, + Repo: repo, + Issue: issue, + OldTitle: note.GetLink().String(), + Content: note.Content.String(), + }) + return err +} diff --git a/routers/api/v1/activitypub/follow.go b/routers/api/v1/activitypub/follow.go new file mode 100644 index 0000000000000..e2ebe14852496 --- /dev/null +++ b/routers/api/v1/activitypub/follow.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 activitypub + +import ( + "context" + "strings" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/services/activitypub" + + ap "github.com/go-ap/activitypub" +) + +// Process an incoming Follow activity +func follow(ctx context.Context, follow ap.Follow) error { + // Actor is the user performing the follow + actorIRI := follow.Actor.GetLink() + actorUser, err := activitypub.PersonIRIToUser(ctx, actorIRI) + if err != nil { + return err + } + + // Object is the user being followed + objectIRI := follow.Object.GetLink() + objectUser, err := activitypub.PersonIRIToUser(ctx, objectIRI) + // Must be a local user + if err != nil || strings.Contains(objectUser.Name, "@") { + return err + } + + err = user_model.FollowUser(actorUser.ID, objectUser.ID) + if err != nil { + return err + } + + // Send back an Accept activity + accept := ap.AcceptNew(objectIRI, follow) + accept.Actor = ap.Person{ID: objectIRI} + accept.To = ap.ItemCollection{ap.IRI(actorIRI.String() + "/inbox")} + accept.Object = follow + return activitypub.Send(objectUser, accept) +} + +// Process an incoming Undo follow activity +func unfollow(ctx context.Context, unfollow ap.Undo) error { + // Object contains the follow + follow, err := ap.To[ap.Follow](unfollow.Object) + if err != nil { + return err + } + + // Actor is the user performing the undo follow + actorIRI := follow.Actor.GetLink() + actorUser, err := activitypub.PersonIRIToUser(ctx, actorIRI) + if err != nil { + return err + } + + // Object is the user being unfollowed + objectIRI := follow.Object.GetLink() + objectUser, err := activitypub.PersonIRIToUser(ctx, objectIRI) + // Must be a local user + if err != nil || strings.Contains(objectUser.Name, "@") { + return err + } + + return user_model.UnfollowUser(actorUser.ID, objectUser.ID) +} diff --git a/routers/api/v1/activitypub/note.go b/routers/api/v1/activitypub/note.go new file mode 100644 index 0000000000000..0033aa4aa5611 --- /dev/null +++ b/routers/api/v1/activitypub/note.go @@ -0,0 +1,64 @@ +// 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 ( + "strconv" + + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/services/activitypub" +) + +// Note function returns the Note object for a comment to an issue or PR +func Note(ctx *context.APIContext) { + // swagger:operation GET /activitypub/note/{username}/{reponame}/{id}/{noteid} activitypub activitypubNote + // --- + // summary: Returns the Note object for a comment to an issue or PR + // 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 repo + // type: string + // required: true + // - name: id + // in: path + // description: ID number of the issue or PR + // type: string + // required: true + // - name: noteid + // in: path + // description: ID number of the comment + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActivityPub" + + index, err := strconv.ParseInt(ctx.Params("noteid"), 10, 64) + if err != nil { + ctx.ServerError("ParseInt", err) + return + } + // TODO: index can be spoofed!!! + comment, err := issues_model.GetCommentByID(ctx, index) + if err != nil { + ctx.ServerError("GetCommentByID", err) + return + } + note, err := activitypub.Note(comment) + if err != nil { + ctx.ServerError("Note", err) + return + } + response(ctx, note) +} diff --git a/routers/api/v1/activitypub/person.go b/routers/api/v1/activitypub/person.go index 542ae7e1206ec..4068dfcf6fd88 100644 --- a/routers/api/v1/activitypub/person.go +++ b/routers/api/v1/activitypub/person.go @@ -5,16 +5,24 @@ package activitypub import ( + "fmt" + "io" "net/http" "strings" - "code.gitea.io/gitea/modules/activitypub" + "code.gitea.io/gitea/models/activities" + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/convert" + "code.gitea.io/gitea/modules/forgefed" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/routers/api/v1/utils" + "code.gitea.io/gitea/services/activitypub" ap "github.com/go-ap/activitypub" - "github.com/go-ap/jsonld" ) // Person function returns the Person actor for a user @@ -23,7 +31,7 @@ func Person(ctx *context.APIContext) { // --- // summary: Returns the Person actor for a user // produces: - // - application/json + // - application/activity+json // parameters: // - name: username // in: path @@ -34,8 +42,8 @@ func Person(ctx *context.APIContext) { // "200": // "$ref": "#/responses/ActivityPub" - link := strings.TrimSuffix(setting.AppURL, "/") + "/api/v1/activitypub/user/" + ctx.ContextUser.Name - person := ap.PersonNew(ap.IRI(link)) + iri := ctx.ContextUser.GetIRI() + person := ap.PersonNew(ap.IRI(iri)) person.Name = ap.NaturalLanguageValuesNew() err := person.Name.Set("en", ap.Content(ctx.ContextUser.FullName)) @@ -52,19 +60,22 @@ func Person(ctx *context.APIContext) { } person.URL = ap.IRI(ctx.ContextUser.HTMLURL()) + person.Location = ap.IRI(ctx.ContextUser.GetEmail()) person.Icon = ap.Image{ Type: ap.ImageType, MediaType: "image/png", - URL: ap.IRI(ctx.ContextUser.AvatarLink()), + URL: ap.IRI(ctx.ContextUser.AvatarFullLinkWithSize(2048)), } - person.Inbox = ap.IRI(link + "/inbox") - person.Outbox = ap.IRI(link + "/outbox") - - person.PublicKey.ID = ap.IRI(link + "#main-key") - person.PublicKey.Owner = ap.IRI(link) + person.Inbox = ap.IRI(iri + "/inbox") + person.Outbox = ap.IRI(iri + "/outbox") + person.Following = ap.IRI(iri + "/following") + person.Followers = ap.IRI(iri + "/followers") + person.Liked = ap.IRI(iri + "/liked") + person.PublicKey.ID = ap.IRI(iri + "#main-key") + person.PublicKey.Owner = ap.IRI(iri) publicKeyPem, err := activitypub.GetPublicKey(ctx.ContextUser) if err != nil { ctx.ServerError("GetPublicKey", err) @@ -72,16 +83,7 @@ func Person(ctx *context.APIContext) { } person.PublicKey.PublicKeyPem = publicKeyPem - binary, err := jsonld.WithContext(jsonld.IRI(ap.ActivityBaseURI), jsonld.IRI(ap.SecurityContextURI)).Marshal(person) - if err != nil { - ctx.ServerError("MarshalJSON", err) - return - } - ctx.Resp.Header().Add("Content-Type", activitypub.ActivityStreamsContentType) - ctx.Resp.WriteHeader(http.StatusOK) - if _, err = ctx.Resp.Write(binary); err != nil { - log.Error("write to resp err: %v", err) - } + response(ctx, person) } // PersonInbox function handles the incoming data for a user inbox @@ -90,7 +92,7 @@ func PersonInbox(ctx *context.APIContext) { // --- // summary: Send to the inbox // produces: - // - application/json + // - application/activity+json // parameters: // - name: username // in: path @@ -101,5 +103,266 @@ func PersonInbox(ctx *context.APIContext) { // "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) + return + } + + var activity ap.Activity + err = activity.UnmarshalJSON(body) + if err != nil { + ctx.ServerError("UnmarshalJSON", err) + return + } + + // Make sure keyID matches the user doing the activity + _, keyID, _ := getKeyID(ctx.Req) + if activity.Actor != nil && !strings.HasPrefix(keyID, activity.Actor.GetLink().String()) { + ctx.ServerError("Actor does not match HTTP signature keyID", nil) + return + } + if activity.AttributedTo != nil && !strings.HasPrefix(keyID, activity.AttributedTo.GetLink().String()) { + ctx.ServerError("AttributedTo does not match HTTP signature keyID", nil) + return + } + // TODO: Check activity.Object actor and attributedTo + + // Process activity + switch activity.Type { + case ap.FollowType: + err = follow(ctx, activity) + case ap.UndoType: + err = unfollow(ctx, activity) + case ap.CreateType: + // TODO: this is kinda a hack + err = ap.OnObject(activity.Object, func(n *ap.Note) error { + noteIRI := n.InReplyTo.GetLink().String() + noteIRISplit := strings.Split(noteIRI, "/") + n.Context = ap.IRI(strings.TrimSuffix(noteIRI, "/"+noteIRISplit[len(noteIRISplit)-1])) + return createComment(ctx, n) + }) + default: + log.Info("Incoming unsupported ActivityStreams type: %s", activity.GetType()) + ctx.PlainText(http.StatusNotImplemented, "ActivityStreams type not supported") + return + } + if err != nil { + ctx.ServerError("Could not process activity", err) + 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" + + iri := ctx.ContextUser.GetIRI() + + orderedCollection := ap.OrderedCollectionNew(ap.IRI(iri + "/outbox")) + orderedCollection.First = ap.IRI(iri + "/outbox?page=1") + + outbox := ap.OrderedCollectionPageNew(orderedCollection) + outbox.First = ap.IRI(iri + "/outbox?page=1") + + feed, err := activities.GetFeeds(ctx, activities.GetFeedsOptions{ + RequestedUser: ctx.ContextUser, + RequestedActionType: activities.ActionCreateRepo, + Actor: ctx.Doer, + IncludePrivate: false, + IncludeDeleted: false, + ListOptions: utils.GetListOptions(ctx), + }) + + // Only specify next if this amount of feed corresponds to the calculated limit. + if len(feed) == convert.ToCorrectPageSize(ctx.FormInt("limit")) { + outbox.Next = ap.IRI(fmt.Sprintf("%s/outbox?page=%d", iri, ctx.FormInt("page")+1)) + } + + // Only specify previous page when there is one. + if ctx.FormInt("page") > 1 { + outbox.Prev = ap.IRI(fmt.Sprintf("%s/outbox?page=%d", iri, ctx.FormInt("page")-1)) + } + + if err != nil { + ctx.ServerError("Couldn't fetch feed", err) + return + } + + for _, action := range feed { + // Created a repo + object := ap.Note{Type: ap.NoteType, Content: ap.NaturalLanguageValuesNew()} + _ = object.Content.Set("en", ap.Content(action.GetRepoName())) + create := ap.Create{Type: ap.CreateType, Object: object} + err := outbox.OrderedItems.Append(create) + if err != nil { + ctx.ServerError("OrderedItems.Append", err) + return + } + } + + // TODO: Remove this code and implement an ActionStarRepo type, so `GetFeeds` + // can handle this with correct pagination and ordering. + stars, err := repo_model.GetStarredRepos(ctx.ContextUser.ID, false, db.ListOptions{Page: 1, PageSize: 1000000}) + if err != nil { + ctx.ServerError("Couldn't fetch stars", err) + return + } + + for _, star := range stars { + object := ap.Note{Type: ap.NoteType, Content: ap.NaturalLanguageValuesNew()} + _ = object.Content.Set("en", ap.Content("Starred "+star.Name)) + create := ap.Create{Type: ap.CreateType, Object: object} + err := outbox.OrderedItems.Append(create) + if err != nil { + ctx.ServerError("OrderedItems.Append", err) + return + } + } + + 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" + + iri := ctx.ContextUser.GetIRI() + + users, _, err := user_model.GetUserFollowing(ctx, ctx.ContextUser, ctx.Doer, utils.GetListOptions(ctx)) + if err != nil { + ctx.ServerError("GetUserFollowing", err) + return + } + + following := ap.OrderedCollectionNew(ap.IRI(iri + "/following")) + following.TotalItems = uint(len(users)) + + for _, user := range users { + // TODO: handle non-Federated users + person := ap.PersonNew(ap.IRI(user.Website)) + err := following.OrderedItems.Append(person) + if err != nil { + ctx.ServerError("OrderedItems.Append", err) + return + } + } + + 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" + + iri := ctx.ContextUser.GetIRI() + + users, _, err := user_model.GetUserFollowers(ctx, ctx.ContextUser, ctx.Doer, utils.GetListOptions(ctx)) + if err != nil { + ctx.ServerError("GetUserFollowers", err) + return + } + + followers := ap.OrderedCollectionNew(ap.IRI(iri + "/followers")) + followers.TotalItems = uint(len(users)) + + for _, user := range users { + // TODO: handle non-Federated users + person := ap.PersonNew(ap.IRI(user.Website)) + err := followers.OrderedItems.Append(person) + if err != nil { + ctx.ServerError("OrderedItems.Append", err) + return + } + } + + 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" + + iri := ctx.ContextUser.GetIRI() + + 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(iri + "/liked")) + liked.TotalItems = uint(count) + + for _, repo := range repos { + // TODO: Handle remote starred repos + repo := forgefed.RepositoryNew(ap.IRI(setting.AppURL + "api/v1/activitypub/repo/" + repo.OwnerName + "/" + repo.Name)) + err := liked.OrderedItems.Append(repo) + if err != nil { + ctx.ServerError("OrderedItems.Append", err) + return + } + } + + 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..df1e5e2335e25 --- /dev/null +++ b/routers/api/v1/activitypub/repo.go @@ -0,0 +1,210 @@ +// 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/modules/context" + "code.gitea.io/gitea/modules/forgefed" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + + ap "github.com/go-ap/activitypub" +) + +// Repo function returns the Repository actor of a repo +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" + + iri := ctx.Repo.Repository.GetIRI() + repo := forgefed.RepositoryNew(ap.IRI(iri)) + + 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(setting.AppURL + "api/v1/activitypub/user/" + ctx.ContextUser.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(iri + "/inbox") + repo.Outbox = ap.IRI(iri + "/outbox") + repo.Followers = ap.IRI(iri + "/followers") + repo.Team = ap.IRI(iri + "/team") + + response(ctx, repo) +} + +// RepoInbox function handles the incoming data for a repo inbox +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) + return + } + + ap.ItemTyperFunc = forgefed.GetItemByType + ap.JSONItemUnmarshal = forgefed.JSONUnmarshalerFn + ap.NotEmptyChecker = forgefed.NotEmpty + var activity ap.Activity + err = activity.UnmarshalJSON(body) + if err != nil { + ctx.ServerError("UnmarshalJSON", err) + return + } + + // Make sure keyID matches the user doing the activity + _, keyID, _ := getKeyID(ctx.Req) + if activity.Actor != nil && !strings.HasPrefix(keyID, activity.Actor.GetLink().String()) { + ctx.ServerError("Actor does not match HTTP signature keyID", nil) + return + } + if activity.AttributedTo != nil && !strings.HasPrefix(keyID, activity.AttributedTo.GetLink().String()) { + ctx.ServerError("AttributedTo does not match HTTP signature keyID", nil) + return + } + + if activity.Object == nil { + ctx.ServerError("Activity does not contain object", err) + return + } + + // Process activity + switch activity.Type { + case ap.CreateType: + switch activity.Object.GetType() { + case forgefed.RepositoryType: + // Fork created by remote instance + err = forgefed.OnRepository(activity.Object, func(r *forgefed.Repository) error { + return createRepository(ctx, r) + }) + case forgefed.TicketType: + // New issue or pull request + err = forgefed.OnTicket(activity.Object, func(t *forgefed.Ticket) error { + return createTicket(ctx, t) + }) + case ap.NoteType: + // New comment + err = ap.On(activity.Object, func(n *ap.Note) error { + return createComment(ctx, n) + }) + default: + log.Info("Incoming unsupported ActivityStreams object type: %s", activity.Object.GetType()) + ctx.PlainText(http.StatusNotImplemented, "ActivityStreams object type not supported") + return + } + case ap.LikeType: + err = star(ctx, activity) + default: + ctx.PlainText(http.StatusNotImplemented, "ActivityStreams type not supported") + return + } + if err != nil { + ctx.ServerError("Error when processing", err) + } + + ctx.Status(http.StatusNoContent) +} + +// RepoOutbox function returns the repo's Outbox OrderedCollection +func RepoOutbox(ctx *context.APIContext) { + // swagger:operation GET /activitypub/repo/{username}/{reponame}/outbox activitypub activitypubRepoOutbox + // --- + // 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" + + // TODO + ctx.Status(http.StatusNotImplemented) +} + +// RepoFollowers function returns the repo's Followers OrderedCollection +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" + + // TODO + ctx.Status(http.StatusNotImplemented) +} diff --git a/routers/api/v1/activitypub/reqsignature.go b/routers/api/v1/activitypub/reqsignature.go index 649cb488b39c6..1d19c1ddb2cf2 100644 --- a/routers/api/v1/activitypub/reqsignature.go +++ b/routers/api/v1/activitypub/reqsignature.go @@ -9,14 +9,12 @@ import ( "crypto/x509" "encoding/pem" "fmt" - "io" "net/http" "net/url" - "code.gitea.io/gitea/modules/activitypub" gitea_context "code.gitea.io/gitea/modules/context" - "code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/activitypub" ap "github.com/go-ap/activitypub" "github.com/go-fed/httpsig" @@ -44,39 +42,28 @@ func getPublicKeyFromResponse(b []byte, keyID *url.URL) (p crypto.PublicKey, err return p, err } -func fetch(iri *url.URL) (b []byte, err error) { - req := httplib.NewRequest(iri.String(), http.MethodGet) - req.Header("Accept", activitypub.ActivityStreamsContentType) - req.Header("User-Agent", "Gitea/"+setting.AppVer) - resp, err := req.Response() +func getKeyID(r *http.Request) (httpsig.Verifier, string, error) { + v, err := httpsig.NewVerifier(r) if err != nil { - return + return nil, "", err } - 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 b, err + return v, v.KeyId(), nil } 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) + v, ID, err := getKeyID(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) + b, err := activitypub.Fetch(idIRI) if err != nil { return } @@ -87,6 +74,19 @@ func verifyHTTPSignatures(ctx *gitea_context.APIContext) (authenticated bool, er // 3. Verify the other actor's key algo := httpsig.Algorithm(setting.Federation.Algorithms[0]) authenticated = v.Verify(pubKey, algo) == nil + if !authenticated { + return + } + // 4. Create a federated user for the actor + // TODO: This is a very bad place for creating federated users + // We end up creating way more users than necessary! + var person ap.Person + err = person.UnmarshalJSON(b) + if err != nil { + return + } + + err = createPerson(ctx, &person) return authenticated, err } diff --git a/routers/api/v1/activitypub/response.go b/routers/api/v1/activitypub/response.go new file mode 100644 index 0000000000000..9e5c9f4d19d05 --- /dev/null +++ b/routers/api/v1/activitypub/response.go @@ -0,0 +1,36 @@ +// 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/context" + "code.gitea.io/gitea/modules/forgefed" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/services/activitypub" + + ap "github.com/go-ap/activitypub" + "github.com/go-ap/jsonld" +) + +// Respond with an ActivityStreams object +func response(ctx *context.APIContext, v interface{}) { + binary, err := jsonld.WithContext( + jsonld.IRI(ap.ActivityBaseURI), + jsonld.IRI(ap.SecurityContextURI), + jsonld.IRI(forgefed.ForgeFedNamespaceURI), + ).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/activitypub/star.go b/routers/api/v1/activitypub/star.go new file mode 100644 index 0000000000000..df58f77f1a442 --- /dev/null +++ b/routers/api/v1/activitypub/star.go @@ -0,0 +1,28 @@ +// 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" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/services/activitypub" + + ap "github.com/go-ap/activitypub" +) + +// Process a Like activity to star a repository +func star(ctx context.Context, like ap.Like) (err error) { + user, err := activitypub.PersonIRIToUser(ctx, like.Actor.GetLink()) + if err != nil { + return + } + repo, err := activitypub.RepositoryIRIToRepository(ctx, like.Object.GetLink()) + if err != nil || strings.Contains(repo.Name, "@") || repo.IsPrivate { + return + } + return repo_model.StarRepo(user.ID, repo.ID, true) +} diff --git a/routers/api/v1/activitypub/ticket.go b/routers/api/v1/activitypub/ticket.go new file mode 100644 index 0000000000000..97e5ae7926b93 --- /dev/null +++ b/routers/api/v1/activitypub/ticket.go @@ -0,0 +1,58 @@ +// 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 ( + "strconv" + + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/services/activitypub" +) + +// Ticket function returns the Ticket object for an issue or PR +func Ticket(ctx *context.APIContext) { + // swagger:operation GET /activitypub/ticket/{username}/{reponame}/{id} activitypub forgefedTicket + // --- + // summary: Returns the Ticket object for an issue or PR + // 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 repo + // type: string + // required: true + // - name: id + // in: path + // description: ID number of the issue or PR + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActivityPub" + + index, err := strconv.ParseInt(ctx.Params("id"), 10, 64) + if err != nil { + ctx.ServerError("ParseInt", err) + return + } + issue, err := issues_model.GetIssueByIndex(ctx.Repo.Repository.ID, index) + if err != nil { + ctx.ServerError("GetIssueByIndex", err) + return + } + ticket, err := activitypub.Ticket(issue) + if err != nil { + ctx.ServerError("Ticket", err) + return + } + response(ctx, ticket) +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 0d11674aa9971..6f8ac7c1468a9 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -644,12 +644,25 @@ func Routes(ctx gocontext.Context) *web.Route { } m.Get("/version", misc.Version) if setting.Federation.Enabled { + m.Get("/authorize_interaction", activitypub.AuthorizeInteraction) 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("/ticket/{username}/{reponame}/{id}", repoAssignment(), activitypub.Ticket) + m.Get("/note/{username}/{reponame}/{id}/{noteid}", repoAssignment(), activitypub.Note) }) } m.Get("/signing-key.gpg", misc.SigningKey) diff --git a/routers/api/v1/user/follower.go b/routers/api/v1/user/follower.go index 22f8f40e1c810..f0109d7d6eec9 100644 --- a/routers/api/v1/user/follower.go +++ b/routers/api/v1/user/follower.go @@ -13,6 +13,7 @@ import ( "code.gitea.io/gitea/modules/convert" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/routers/api/v1/utils" + user_service "code.gitea.io/gitea/services/user" ) func responseAPIUsers(ctx *context.APIContext, users []*user_model.User) { @@ -219,7 +220,7 @@ func Follow(ctx *context.APIContext) { // "204": // "$ref": "#/responses/empty" - if err := user_model.FollowUser(ctx.Doer.ID, ctx.ContextUser.ID); err != nil { + if err := user_service.FollowUser(ctx.Doer.ID, ctx.ContextUser.ID); err != nil { ctx.Error(http.StatusInternalServerError, "FollowUser", err) return } @@ -241,7 +242,7 @@ func Unfollow(ctx *context.APIContext) { // "204": // "$ref": "#/responses/empty" - if err := user_model.UnfollowUser(ctx.Doer.ID, ctx.ContextUser.ID); err != nil { + if err := user_service.UnfollowUser(ctx.Doer.ID, ctx.ContextUser.ID); err != nil { ctx.Error(http.StatusInternalServerError, "UnfollowUser", err) return } diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go index 6e16b377dbc3b..bc56407e55494 100644 --- a/routers/web/user/profile.go +++ b/routers/web/user/profile.go @@ -23,6 +23,7 @@ import ( "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/routers/web/feed" "code.gitea.io/gitea/routers/web/org" + user_service "code.gitea.io/gitea/services/user" ) // Profile render user's profile page @@ -302,9 +303,9 @@ func Action(ctx *context.Context) { var err error switch ctx.FormString("action") { case "follow": - err = user_model.FollowUser(ctx.Doer.ID, ctx.ContextUser.ID) + err = user_service.FollowUser(ctx.Doer.ID, ctx.ContextUser.ID) case "unfollow": - err = user_model.UnfollowUser(ctx.Doer.ID, ctx.ContextUser.ID) + err = user_service.UnfollowUser(ctx.Doer.ID, ctx.ContextUser.ID) } if err != nil { diff --git a/routers/web/webfinger.go b/routers/web/webfinger.go index 972182becee72..3b4977a3af998 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"` } @@ -107,6 +108,10 @@ func WebfingerQuery(ctx *context.Context) { 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", "*") diff --git a/services/activitypub/activities.go b/services/activitypub/activities.go new file mode 100644 index 0000000000000..4aad43e8d7771 --- /dev/null +++ b/services/activitypub/activities.go @@ -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 ( + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + + ap "github.com/go-ap/activitypub" +) + +// Create and send Follow activity +func Follow(actorUser, followUser *user_model.User) *ap.Follow { + object := ap.PersonNew(ap.IRI(followUser.LoginName)) + follow := ap.FollowNew("", object) + follow.Type = ap.FollowType + follow.Actor = ap.PersonNew(ap.IRI(setting.AppURL + "api/v1/activitypub/user/" + actorUser.Name)) + follow.To = ap.ItemCollection{ap.Item(ap.IRI(followUser.LoginName + "/inbox"))} + return follow +} + +// Create and send Undo Follow activity +func Unfollow(actorUser, followUser *user_model.User) *ap.Undo { + object := ap.PersonNew(ap.IRI(followUser.LoginName)) + follow := ap.FollowNew("", object) + follow.Actor = ap.PersonNew(ap.IRI(setting.AppURL + "api/v1/activitypub/user/" + actorUser.Name)) + unfollow := ap.UndoNew("", follow) + unfollow.Type = ap.UndoType + unfollow.To = ap.ItemCollection{ap.Item(ap.IRI(followUser.LoginName + "/inbox"))} + return unfollow +} diff --git a/modules/activitypub/client.go b/services/activitypub/client.go similarity index 100% rename from modules/activitypub/client.go rename to services/activitypub/client.go diff --git a/modules/activitypub/client_test.go b/services/activitypub/client_test.go similarity index 100% rename from modules/activitypub/client_test.go rename to services/activitypub/client_test.go diff --git a/services/activitypub/create.go b/services/activitypub/create.go new file mode 100644 index 0000000000000..344cf330dbe08 --- /dev/null +++ b/services/activitypub/create.go @@ -0,0 +1,17 @@ +// 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 ( + ap "github.com/go-ap/activitypub" +) + +func Create(to string, object ap.ObjectOrLink) *ap.Create { + return &ap.Create{ + Type: ap.CreateType, + Object: object, + To: ap.ItemCollection{ap.Item(ap.IRI(to))}, + } +} diff --git a/services/activitypub/iri.go b/services/activitypub/iri.go new file mode 100644 index 0000000000000..f74c027222bb8 --- /dev/null +++ b/services/activitypub/iri.go @@ -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" + "errors" + "strconv" + "strings" + + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + + ap "github.com/go-ap/activitypub" +) + +// Returns the username corresponding to a Person actor IRI +func PersonIRIToName(personIRI ap.IRI) (string, error) { + personIRISplit := strings.Split(personIRI.String(), "/") + if len(personIRISplit) < 4 { + return "", errors.New("not a Person actor IRI") + } + + instance := personIRISplit[2] + name := personIRISplit[len(personIRISplit)-1] + if instance == setting.Domain { + // Local user + return name, nil + } + // Remote user + // Get name in username@instance.com format + return name + "@" + instance, nil +} + +// Returns the user corresponding to a Person actor IRI +func PersonIRIToUser(ctx context.Context, personIRI ap.IRI) (*user_model.User, error) { + name, err := PersonIRIToName(personIRI) + if err != nil { + return nil, err + } + + user, err := user_model.GetUserByName(ctx, name) + if err != nil && !strings.Contains(name, "@") { + return user, err + } + + return user_model.GetUserByName(ctx, name) +} + +// Returns the owner and name corresponding to a Repository actor IRI +func RepositoryIRIToName(repoIRI ap.IRI) (string, string, error) { + repoIRISplit := strings.Split(repoIRI.String(), "/") + if len(repoIRISplit) < 5 { + return "", "", errors.New("not a Repository actor IRI") + } + + instance := repoIRISplit[2] + username := repoIRISplit[len(repoIRISplit)-2] + reponame := repoIRISplit[len(repoIRISplit)-1] + if instance == setting.Domain { + // Local repo + return username, reponame, nil + } + // Remote repo + return username + "@" + instance, reponame, nil +} + +// Returns the repository corresponding to a Repository actor IRI +func RepositoryIRIToRepository(ctx context.Context, repoIRI ap.IRI) (*repo_model.Repository, error) { + username, reponame, err := RepositoryIRIToName(repoIRI) + if err != nil { + return nil, err + } + + // TODO: create remote repo if not exists + return repo_model.GetRepositoryByOwnerAndName(username, reponame) +} + +// Returns the owner, repo name, and idx of a Ticket object IRI +func TicketIRIToName(ticketIRI ap.IRI) (string, string, int64, error) { + ticketIRISplit := strings.Split(ticketIRI.String(), "/") + if len(ticketIRISplit) < 5 { + return "", "", 0, errors.New("not a Ticket object IRI") + } + + instance := ticketIRISplit[2] + username := ticketIRISplit[len(ticketIRISplit)-3] + reponame := ticketIRISplit[len(ticketIRISplit)-2] + idx, err := strconv.ParseInt(ticketIRISplit[len(ticketIRISplit)-1], 10, 64) + if err != nil { + return "", "", 0, err + } + if instance == setting.Domain { + // Local repo + return username, reponame, idx, nil + } + // Remote repo + return username + "@" + instance, reponame, idx, nil +} + +// Returns the owner, repo name, and idx of a Branch object IRI +func BranchIRIToName(ticketIRI ap.IRI) (string, string, string, error) { + ticketIRISplit := strings.Split(ticketIRI.String(), "/") + if len(ticketIRISplit) < 5 { + return "", "", "", errors.New("not a Branch object IRI") + } + + instance := ticketIRISplit[2] + username := ticketIRISplit[len(ticketIRISplit)-3] + reponame := ticketIRISplit[len(ticketIRISplit)-2] + branch := ticketIRISplit[len(ticketIRISplit)-1] + if instance == setting.Domain { + // Local repo + return username, reponame, branch, nil + } + // Remote repo + return username + "@" + instance, reponame, branch, nil +} diff --git a/modules/activitypub/keypair.go b/services/activitypub/keypair.go similarity index 100% rename from modules/activitypub/keypair.go rename to services/activitypub/keypair.go diff --git a/modules/activitypub/keypair_test.go b/services/activitypub/keypair_test.go similarity index 100% rename from modules/activitypub/keypair_test.go rename to services/activitypub/keypair_test.go diff --git a/modules/activitypub/main_test.go b/services/activitypub/main_test.go similarity index 100% rename from modules/activitypub/main_test.go rename to services/activitypub/main_test.go diff --git a/services/activitypub/objects.go b/services/activitypub/objects.go new file mode 100644 index 0000000000000..0169194cda57b --- /dev/null +++ b/services/activitypub/objects.go @@ -0,0 +1,85 @@ +// 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 ( + "strconv" + + "code.gitea.io/gitea/models/db" + issues_model "code.gitea.io/gitea/models/issues" + "code.gitea.io/gitea/modules/forgefed" + + ap "github.com/go-ap/activitypub" +) + +// Construct a Note object from a comment +func Note(comment *issues_model.Comment) (*ap.Note, error) { + err := comment.LoadPoster() + if err != nil { + return nil, err + } + err = comment.LoadIssue() + if err != nil { + return nil, err + } + note := ap.Note{ + Type: ap.NoteType, + ID: ap.IRI(comment.GetIRI()), + AttributedTo: ap.IRI(comment.Poster.GetIRI()), + Context: ap.IRI(comment.Issue.GetIRI()), + To: ap.ItemCollection{ap.IRI("https://www.w3.org/ns/activitystreams#Public")}, + } + note.Content = ap.NaturalLanguageValuesNew() + err = note.Content.Set("en", ap.Content(comment.Content)) + if err != nil { + return nil, err + } + return ¬e, nil +} + +// Construct a Ticket object from an issue +func Ticket(issue *issues_model.Issue) (*forgefed.Ticket, error) { + iri := issue.GetIRI() + ticket := forgefed.TicketNew() + ticket.Type = forgefed.TicketType + ticket.ID = ap.IRI(iri) + + // Setting a NaturalLanguageValue to a number causes go-ap's JSON parsing to do weird things + // Workaround: set it to #1 instead of 1 + ticket.Name = ap.NaturalLanguageValuesNew() + err := ticket.Name.Set("en", ap.Content("#"+strconv.FormatInt(issue.Index, 10))) + if err != nil { + return nil, err + } + + err = issue.LoadRepo(db.DefaultContext) + if err != nil { + return nil, err + } + ticket.Context = ap.IRI(issue.Repo.GetIRI()) + + err = issue.LoadPoster() + if err != nil { + return nil, err + } + ticket.AttributedTo = ap.IRI(issue.Poster.GetIRI()) + + ticket.Summary = ap.NaturalLanguageValuesNew() + err = ticket.Summary.Set("en", ap.Content(issue.Title)) + if err != nil { + return nil, err + } + + ticket.Content = ap.NaturalLanguageValuesNew() + err = ticket.Content.Set("en", ap.Content(issue.Content)) + if err != nil { + return nil, err + } + + if issue.IsClosed { + ticket.IsResolved = true + } + return ticket, nil +} diff --git a/services/activitypub/transport.go b/services/activitypub/transport.go new file mode 100644 index 0000000000000..2a4875024229f --- /dev/null +++ b/services/activitypub/transport.go @@ -0,0 +1,60 @@ +// 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/forgefed" + "code.gitea.io/gitea/modules/httplib" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + + ap "github.com/go-ap/activitypub" + "github.com/go-ap/jsonld" +) + +// Fetch a remote ActivityStreams object +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 b, err +} + +// Send an activity +func Send(user *user_model.User, activity *ap.Activity) error { + binary, err := jsonld.WithContext( + jsonld.IRI(ap.ActivityBaseURI), + jsonld.IRI(ap.SecurityContextURI), + jsonld.IRI(forgefed.ForgeFedNamespaceURI), + ).Marshal(activity) + if err != nil { + return err + } + + for _, to := range activity.To { + client, _ := NewClient(user, setting.AppURL+"api/v1/activitypub/user/"+user.Name+"#main-key") + resp, _ := client.Post(binary, to.GetLink().String()) + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, setting.Federation.MaxSize)) + log.Trace("Response from sending activity", string(respBody)) + } + return err +} diff --git a/modules/activitypub/user_settings.go b/services/activitypub/user_settings.go similarity index 100% rename from modules/activitypub/user_settings.go rename to services/activitypub/user_settings.go diff --git a/modules/activitypub/user_settings_test.go b/services/activitypub/user_settings_test.go similarity index 100% rename from modules/activitypub/user_settings_test.go rename to services/activitypub/user_settings_test.go diff --git a/services/comments/comments.go b/services/comments/comments.go index c40631359b733..d2406ba07a438 100644 --- a/services/comments/comments.go +++ b/services/comments/comments.go @@ -5,12 +5,15 @@ package comments import ( + "strings" + "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" repo_model "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/notification" "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/services/activitypub" ) // CreateIssueComment creates a plain issue comment. @@ -27,6 +30,19 @@ func CreateIssueComment(doer *user_model.User, repo *repo_model.Repository, issu return nil, err } + if strings.Contains(repo.OwnerName, "@") { + // Federated comment + note, err := activitypub.Note(comment) + if err != nil { + return nil, err + } + create := activitypub.Create(repo.OriginalURL+"/inbox", note) + err = activitypub.Send(doer, create) + if err != nil { + return nil, err + } + } + mentions, err := issues_model.FindAndUpdateIssueMentions(db.DefaultContext, issue, doer, comment.Content) if err != nil { return nil, err diff --git a/services/issue/issue.go b/services/issue/issue.go index 47782e50d36a2..e9b44d4051add 100644 --- a/services/issue/issue.go +++ b/services/issue/issue.go @@ -6,6 +6,7 @@ package issue import ( "fmt" + "strings" activities_model "code.gitea.io/gitea/models/activities" "code.gitea.io/gitea/models/db" @@ -19,6 +20,7 @@ import ( "code.gitea.io/gitea/modules/notification" "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/activitypub" ) // NewIssue creates new issue with labels for repository. @@ -27,6 +29,19 @@ func NewIssue(repo *repo_model.Repository, issue *issues_model.Issue, labelIDs [ return err } + if strings.Contains(repo.OwnerName, "@") { + // Federated issue + ticket, err := activitypub.Ticket(issue) + if err != nil { + return err + } + create := activitypub.Create(repo.OriginalURL+"/inbox", ticket) + err = activitypub.Send(issue.Poster, create) + if err != nil { + return err + } + } + for _, assigneeID := range assigneeIDs { if err := AddAssigneeIfNotAssigned(issue, issue.Poster, assigneeID); err != nil { return err diff --git a/services/repository/activitypub.go b/services/repository/activitypub.go new file mode 100644 index 0000000000000..bb22f5b1f3685 --- /dev/null +++ b/services/repository/activitypub.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 repository + +import ( + "context" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/activitypub" + "code.gitea.io/gitea/services/migrations" + + ap "github.com/go-ap/activitypub" +) + +func CreateFork(ctx context.Context, instance, username, reponame, destUsername string) error { + // TODO: Clean this up + + // Migrate repository code + user, err := user_model.GetUserByName(ctx, destUsername) + if err != nil { + return err + } + + _, err = migrations.MigrateRepository(ctx, user, destUsername, migrations.MigrateOptions{ + CloneAddr: "https://" + instance + "/" + username + "/" + reponame + ".git", + RepoName: reponame, + }, nil) + if err != nil { + return err + } + + // TODO: Make the migrated repo a fork + + // Send a Create activity to the instance we are forking from + create := ap.Create{Type: ap.CreateType} + create.To = ap.ItemCollection{ap.IRI("https://" + instance + "/api/v1/activitypub/repo/" + username + "/" + reponame + "/inbox")} + repo := ap.IRI(setting.AppURL + "api/v1/activitypub/repo/" + destUsername + "/" + reponame) + // repo := forgefed.RepositoryNew(ap.IRI(setting.AppURL + "api/v1/activitypub/repo/" + destUsername + "/" + reponame)) + // repo.ForkedFrom = forgefed.RepositoryNew(ap.IRI()) + create.Object = repo + + return activitypub.Send(user, &create) +} diff --git a/services/user/user.go b/services/user/user.go index c8b497a5c46f8..8dae88378a28c 100644 --- a/services/user/user.go +++ b/services/user/user.go @@ -14,6 +14,7 @@ import ( "code.gitea.io/gitea/models" asymkey_model "code.gitea.io/gitea/models/asymkey" + "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" packages_model "code.gitea.io/gitea/models/packages" @@ -26,6 +27,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/activitypub" "code.gitea.io/gitea/services/packages" ) @@ -280,3 +282,53 @@ func DeleteAvatar(u *user_model.User) error { } return nil } + +// FollowUser marks someone be another's follower. +func FollowUser(userID, followID int64) (err error) { + if userID == followID || user_model.IsFollowing(userID, followID) { + return nil + } + + followUser, err := user_model.GetUserByID(followID) + if err != nil { + return + } + if followUser.LoginType == auth.Federated { + // Following remote user + actorUser, err := user_model.GetUserByID(userID) + if err != nil { + return err + } + err = activitypub.Send(actorUser, activitypub.Follow(actorUser, followUser)) + if err != nil { + return err + } + } + + return user_model.FollowUser(userID, followID) +} + +// UnfollowUser unmarks someone as another's follower. +func UnfollowUser(userID, followID int64) (err error) { + if userID == followID || !user_model.IsFollowing(userID, followID) { + return nil + } + + followUser, err := user_model.GetUserByID(followID) + if err != nil { + return + } + if followUser.LoginType == auth.Federated { + // Unfollowing remote user + actorUser, err := user_model.GetUserByID(userID) + if err != nil { + return err + } + err = activitypub.Send(actorUser, activitypub.Unfollow(actorUser, followUser)) + if err != nil { + return err + } + } + + return user_model.UnfollowUser(userID, followID) +} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 229e219064192..74673da4dfe1e 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -23,10 +23,182 @@ }, "basePath": "{{AppSubUrl | JSEscape | Safe}}/api/v1", "paths": { + "/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/repo/{username}/{reponame}/outbox": { + "get": { + "produces": [ + "application/activity+json" + ], + "tags": [ + "activitypub" + ], + "summary": "Returns the outbox", + "operationId": "activitypubRepoOutbox", + "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/ticket/{username}/{reponame}/{id}": { + "get": { + "produces": [ + "application/activity+json" + ], + "tags": [ + "activitypub" + ], + "summary": "Returns the Ticket object for an issue or PR", + "operationId": "forgefedTicket", + "parameters": [ + { + "type": "string", + "description": "username of the user", + "name": "username", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "reponame", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "ID number of the issue or PR", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ActivityPub" + } + } + } + }, "/activitypub/user/{username}": { "get": { "produces": [ - "application/json" + "application/activity+json" ], "tags": [ "activitypub" @@ -49,10 +221,62 @@ } } }, + "/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/json" + "application/activity+json" ], "tags": [ "activitypub" @@ -75,6 +299,32 @@ } } }, + "/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": [ diff --git a/tests/integration/api_activitypub_person_test.go b/tests/integration/api_activitypub_person_test.go index e7ef79d156d6f..0765a464556c2 100644 --- a/tests/integration/api_activitypub_person_test.go +++ b/tests/integration/api_activitypub_person_test.go @@ -13,9 +13,9 @@ import ( "testing" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/activitypub" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/routers" + "code.gitea.io/gitea/services/activitypub" ap "github.com/go-ap/activitypub" "github.com/stretchr/testify/assert" @@ -42,10 +42,10 @@ func TestActivityPubPerson(t *testing.T) { assert.Equal(t, ap.PersonType, person.Type) assert.Equal(t, username, person.PreferredUsername.String()) - keyID := person.GetID().String() + keyID := person.GetLink().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()) + assert.Regexp(t, fmt.Sprintf("activitypub/user/%s/outbox$", username), person.Outbox.GetLink().String()) + assert.Regexp(t, fmt.Sprintf("activitypub/user/%s/inbox$", username), person.Inbox.GetLink().String()) pubKey := person.PublicKey assert.NotNil(t, pubKey)