diff options
author | Anthony Wang | 2023-03-16 01:45:24 +0000 |
---|---|---|
committer | Anthony Wang | 2023-03-16 01:45:24 +0000 |
commit | 0fe1a26298c0776adb5e05ad6780402d82fb3598 (patch) | |
tree | c2244a12e9f17c7adef9b2eda305a80bbe3d5c6e | |
parent | c43ad74240e72ce57a5000fa134292732602acf7 (diff) |
Copy federation implementation from forgejo-federation branch
55 files changed, 3051 insertions, 105 deletions
diff --git a/FEDERATION.md b/FEDERATION.md new file mode 100644 index 000000000..86e74892a --- /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 82ce64ce0..74db95b38 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -2446,7 +2446,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. ;; @@ -35,7 +35,7 @@ require ( github.com/felixge/fgprof v0.9.3 github.com/fsnotify/fsnotify v1.6.0 github.com/gliderlabs/ssh v0.3.5 - github.com/go-ap/activitypub v0.0.0-20230218112952-bfb607b04799 + github.com/go-ap/activitypub v0.0.0-20230307141717-3566110d71a0 github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73 github.com/go-chi/chi/v5 v5.0.8 github.com/go-chi/cors v1.2.1 @@ -97,6 +97,7 @@ require ( github.com/tstranex/u2f v1.0.0 github.com/unrolled/render v1.5.0 github.com/urfave/cli v1.22.12 + github.com/valyala/fastjson v1.6.4 github.com/xanzy/go-gitlab v0.80.2 github.com/xeipuuv/gojsonschema v1.2.0 github.com/yohcop/openid-go v1.0.0 @@ -260,7 +261,6 @@ require ( github.com/toqueteos/webbrowser v1.2.0 // indirect github.com/ulikunitz/xz v0.5.11 // indirect github.com/unknwon/com v1.0.1 // indirect - github.com/valyala/fastjson v1.6.4 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect @@ -352,8 +352,8 @@ 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-20230218112952-bfb607b04799 h1:zVZaYt1h4yWL7uRHvq2StewCu4ObtS+ws9gGgoZJ+2s= -github.com/go-ap/activitypub v0.0.0-20230218112952-bfb607b04799/go.mod h1:1oVD0h0aPT3OEE1ZoSUoym/UGKzxe+e0y8K2AkQ1Hqs= +github.com/go-ap/activitypub v0.0.0-20230307141717-3566110d71a0 h1:ll+jcwBW55vQDUV3jHuua/0wqjTm2GIh/iP1wwjbPSc= +github.com/go-ap/activitypub v0.0.0-20230307141717-3566110d71a0/go.mod h1:1oVD0h0aPT3OEE1ZoSUoym/UGKzxe+e0y8K2AkQ1Hqs= github.com/go-ap/errors v0.0.0-20221205040414-01c1adfc98ea h1:ywGtLGVjJjMrq4mu35Qmu+NtlhlTk/gTayE6Bb4tQZk= github.com/go-ap/errors v0.0.0-20221205040414-01c1adfc98ea/go.mod h1:SaTNjEEkp0q+w3pUS1ccyEL/lUrHteORlDq/e21mCc8= github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73 h1:GMKIYXyXPGIp+hYiWOhfqK4A023HdgisDT4YGgf99mw= diff --git a/models/auth/source.go b/models/auth/source.go index bade2fecf..23fc57793 100644 --- a/models/auth/source.go +++ b/models/auth/source.go @@ -22,14 +22,15 @@ type Type int // Note: new type must append to the end of list to maintain compatibility. const ( - NoType Type = iota - Plain // 1 - LDAP // 2 - SMTP // 3 - PAM // 4 - DLDAP // 5 - OAuth2 // 6 - SSPI // 7 + NoType Type = iota + Plain // 1 + LDAP // 2 + SMTP // 3 + PAM // 4 + DLDAP // 5 + OAuth2 // 6 + SSPI // 7 + Federated // 8 ) // String returns the string name of the LoginType @@ -178,6 +179,11 @@ func (source *Source) IsSSPI() bool { return source.Type == SSPI } +// IsFederated returns true of this source is of the Federated type. +func (source *Source) IsFederated() bool { + return source.Type == Federated +} + // HasTLS returns true of this source supports TLS. func (source *Source) HasTLS() bool { hasTLSer, ok := source.Cfg.(HasTLSer) diff --git a/models/issues/comment.go b/models/issues/comment.go index a47dc7c15..bba270d4c 100644 --- a/models/issues/comment.go +++ b/models/issues/comment.go @@ -9,6 +9,7 @@ import ( "context" "fmt" "strconv" + "strings" "unicode/utf8" "code.gitea.io/gitea/models/db" @@ -21,6 +22,7 @@ import ( "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "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" @@ -1249,3 +1251,18 @@ func FixCommentTypeLabelWithOutsideLabels(ctx context.Context) (int64, error) { func (c *Comment) HasOriginalAuthor() bool { return c.OriginalAuthor != "" && c.OriginalAuthorID != 0 } + +func (c *Comment) GetIRI(ctx context.Context) string { + err := c.LoadIssue(ctx) + if err != nil { + return "" + } + err = c.Issue.LoadRepo(ctx) + 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.ID, 10) +} diff --git a/models/issues/issue.go b/models/issues/issue.go index edd74261e..62727a197 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -9,6 +9,7 @@ import ( "fmt" "regexp" "sort" + "strconv" "strings" "code.gitea.io/gitea/models/db" @@ -24,6 +25,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" @@ -2498,3 +2500,14 @@ func DeleteOrphanedIssues(ctx context.Context) error { func (issue *Issue) HasOriginalAuthor() bool { return issue.OriginalAuthor != "" && issue.OriginalAuthorID != 0 } + +func (issue *Issue) GetIRI(ctx context.Context) string { + err := issue.LoadRepo(ctx) + 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 dcffb63fd..db85946da 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -5,6 +5,7 @@ package repo import ( "context" + "errors" "fmt" "html/template" "net" @@ -670,6 +671,28 @@ func GetRepositoryByID(ctx context.Context, id int64) (*Repository, error) { return repo, nil } +// GetRepositoryByIRI returns the repository by given IRI if exists. +func GetRepositoryByIRI(ctx context.Context, iri string) (*Repository, error) { + iriSplit := strings.Split(iri, "/") + if len(iriSplit) < 5 { + return nil, errors.New("not a Repository actor IRI") + } + if iriSplit[2] == setting.Domain { + // Local repository + return GetRepositoryByOwnerAndName(ctx, iriSplit[len(iriSplit)-2], iriSplit[len(iriSplit)-1]) + } + repo := &Repository{ + OriginalURL: iri, + } + has, err := db.GetEngine(ctx).Get(repo) + if err != nil { + return nil, err + } else if !has { + return nil, ErrRepoNotExist{0, 0, "", ""} + } + return repo, err +} + // GetRepositoriesMapByIDs returns the repositories by given id slice. func GetRepositoriesMapByIDs(ids []int64) (map[int64]*Repository, error) { repos := make(map[int64]*Repository, len(ids)) @@ -772,3 +795,10 @@ func FixNullArchivedRepository(ctx context.Context) (int64, error) { IsArchived: false, }) } + +func (repo *Repository) GetIRI() string { + if strings.Contains(repo.OwnerName, "@") { + return repo.OriginalURL + } + return setting.AppURL + "api/v1/activitypub/repo/" + repo.OwnerName + "/" + repo.Name +} diff --git a/models/user/avatar.go b/models/user/avatar.go index 3d1e2ed20..72ccd090d 100644 --- a/models/user/avatar.go +++ b/models/user/avatar.go @@ -100,6 +100,15 @@ func (u *User) AvatarLink(ctx context.Context) string { return link } +// AvatarFullLinkWithSize returns the full avatar link with size and http host +func (u *User) AvatarFullLinkWithSize(ctx context.Context, size int) string { + link := u.AvatarLinkWithSize(ctx, 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 185747ac2..563bccabb 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -7,6 +7,7 @@ package user import ( "context" "encoding/hex" + "errors" "fmt" "net/url" "os" @@ -969,6 +970,29 @@ func GetUserByName(ctx context.Context, name string) (*User, error) { return u, nil } +// GetUserByIRI returns user by given IRI. +func GetUserByIRI(ctx context.Context, iri string) (*User, error) { + if len(iri) == 0 { + return nil, ErrUserNotExist{0, iri, 0} + } + iriSplit := strings.Split(iri, "/") + if len(iriSplit) < 4 { + return nil, errors.New("not a Person actor IRI") + } + if iriSplit[2] == setting.Domain { + // Local user + return GetUserByName(ctx, iriSplit[len(iriSplit)-1]) + } + u := &User{LoginName: iri} + has, err := db.GetEngine(ctx).Get(u) + if err != nil { + return nil, err + } else if !has { + return nil, ErrUserNotExist{0, iri, 0} + } + return u, nil +} + // GetUserEmailsByNames returns a list of e-mails corresponds to names of users // that have their email notifications set to enabled or onmention. func GetUserEmailsByNames(ctx context.Context, names []string) []string { @@ -1283,3 +1307,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 000000000..9e122d60c --- /dev/null +++ b/modules/forgefed/branch.go @@ -0,0 +1,98 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +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.JSONWriteItemProp(&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 000000000..f1234b796 --- /dev/null +++ b/modules/forgefed/commit.go @@ -0,0 +1,105 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +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.JSONWriteTimeProp(&b, "created", c.Created) + } + if !c.Committed.IsZero() { + ap.JSONWriteTimeProp(&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 000000000..4dc9eaf22 --- /dev/null +++ b/modules/forgefed/forgefed.go @@ -0,0 +1,97 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +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 000000000..74acdd3ae --- /dev/null +++ b/modules/forgefed/push.go @@ -0,0 +1,110 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +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.JSONWriteItemProp(&b, "target", p.Target) + } + if p.HashBefore != nil { + ap.JSONWriteItemProp(&b, "hashBefore", p.HashBefore) + } + if p.HashAfter != nil { + ap.JSONWriteItemProp(&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 000000000..63680ccd3 --- /dev/null +++ b/modules/forgefed/repository.go @@ -0,0 +1,111 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +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.JSONWriteItemProp(&b, "team", r.Team) + } + if r.Forks != nil { + ap.JSONWriteItemProp(&b, "forks", r.Forks) + } + if r.ForkedFrom != nil { + ap.JSONWriteItemProp(&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 000000000..d623f124c --- /dev/null +++ b/modules/forgefed/repository_test.go @@ -0,0 +1,183 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +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 000000000..1759de547 --- /dev/null +++ b/modules/forgefed/ticket.go @@ -0,0 +1,133 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +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.JSONWriteItemCollectionProp(&b, "dependants", t.Dependants) + } + if t.Dependencies != nil { + ap.JSONWriteItemCollectionProp(&b, "dependencies", t.Dependencies) + } + ap.JSONWriteBoolProp(&b, "isResolved", t.IsResolved) + if t.ResolvedBy != nil { + ap.JSONWriteItemProp(&b, "resolvedBy", t.ResolvedBy) + } + if !t.Resolved.IsZero() { + ap.JSONWriteTimeProp(&b, "resolved", t.Resolved) + } + if t.Origin != nil { + ap.JSONWriteItemProp(&b, "origin", t.Origin) + } + if t.Target != nil { + ap.JSONWriteItemProp(&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 2bea90063..8ea4b986c 100644 --- a/modules/setting/federation.go +++ b/modules/setting/federation.go @@ -22,7 +22,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 3381846b8..4cb62db15 100644 --- a/modules/validation/helpers.go +++ b/modules/validation/helpers.go @@ -92,7 +92,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 000000000..b416e5ae8 --- /dev/null +++ b/routers/api/v1/activitypub/authorize_interaction.go @@ -0,0 +1,92 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package activitypub + +import ( + "net/http" + "strconv" + + 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/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) { + resp, err := activitypub.Fetch(ctx.Req.URL.Query().Get("uri")) + if err != nil { + ctx.ServerError("Fetch", err) + return + } + + ap.ItemTyperFunc = forgefed.GetItemByType + ap.JSONItemUnmarshal = forgefed.JSONUnmarshalerFn + ap.IsNotEmpty = 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("CreatePerson", err) + return + } + user, err := user_model.GetUserByIRI(ctx, object.GetLink().String()) + if err != nil { + ctx.ServerError("GetUserByIRI", err) + return + } + ctx.Redirect(setting.AppURL + user.Name) + case forgefed.RepositoryType: + // Federated repository + err = forgefed.OnRepository(object, func(r *forgefed.Repository) error { + return createRepository(ctx, r) + }) + if err != nil { + ctx.ServerError("CreateRepository", err) + return + } + repo, err := repo_model.GetRepositoryByIRI(ctx, object.GetLink().String()) + if err != nil { + ctx.ServerError("RepositoryIRIToName", err) + return + } + ctx.Redirect(setting.AppURL + repo.OwnerName + "/" + repo.Name) + 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 000000000..4b0ba5edf --- /dev/null +++ b/routers/api/v1/activitypub/create.go @@ -0,0 +1,308 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package activitypub + +import ( + "context" + "errors" + "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" + "code.gitea.io/gitea/modules/json" + 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 { + _, err := user_model.GetUserByIRI(ctx, person.GetLink().String()) + if !user_model.IsErrUserNotExist(err) { + // User already exists + return err + } + + personIRISplit := strings.Split(person.GetLink().String(), "/") + if len(personIRISplit) < 4 { + return errors.New("not a Person actor IRI") + } + + // Get instance by taking the domain of the IRI + instance := personIRISplit[2] + if instance == setting.Domain { + // Local user + return nil + } + + // Send a WebFinger request to get the username + resp, err := activitypub.Fetch("https://" + instance + "/.well-known/webfinger?resource=" + person.GetLink().String()) + if err != nil { + return err + } + var data activitypub.WebfingerJRD + err = json.Unmarshal(resp, &data) + if err != nil { + return err + } + subjectSplit := strings.Split(data.Subject, ":") + if subjectSplit[0] != "acct" { + return errors.New("subject is not an acct URI") + } + name := subjectSplit[1] + + 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, + Email: email, + LoginType: auth.Federated, + LoginName: person.GetLink().String(), + EmailNotificationsPreference: user_model.EmailNotificationsDisabled, + } + if person.Name != nil { + user.FullName = person.Name.String() + } + err = user_model.CreateUser(user) + if err != nil { + return err + } + + if person.Icon != nil { + // Fetch and save user icon + icon, err := ap.ToObject(person.Icon) + if err != nil { + return err + } + body, err := activitypub.Fetch(icon.URL.GetLink().String()) + 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 + } + // Set public key + return user_model.SetUserSetting(user.ID, user_model.UserActivityPubPubPem, person.PublicKey.PublicKeyPem) +} + +// Create a new federated user from a Person IRI +func createPersonFromIRI(ctx context.Context, personIRI ap.IRI) error { + object, err := activitypub.FetchObject(personIRI.String()) + if err != nil { + return err + } + return ap.OnActor(object, func(p *ap.Person) error { + return createPerson(ctx, p) + }) +} + +// Create a new federated repo from a Repository object +func createRepository(ctx context.Context, repository *forgefed.Repository) error { + user, err := user_model.GetUserByIRI(ctx, repository.AttributedTo.GetLink().String()) + if user_model.IsErrUserNotExist(err) { + // TODO: This should probably return the created user too + err := createPersonFromIRI(ctx, repository.AttributedTo.GetLink()) + if err != nil { + return err + } + user, err = user_model.GetUserByIRI(ctx, repository.AttributedTo.GetLink().String()) + if err != nil { + return err + } + } else if err != nil { + return err + } + + // Check if repo exists + _, err = repo_model.GetRepositoryByIRI(ctx, repository.GetLink().String()) + if !repo_model.IsErrRepoNotExist(err) { + return err + } + + repo, err := repo_service.CreateRepository(ctx, 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 := repo_model.GetRepositoryByIRI(ctx, repository.ForkedFrom.GetLink().String()) + if err != nil { + return err + } + repo.ForkID = forkedFrom.ID + } + return nil +} + +// Create a new federated repo from a Repository IRI +func createRepositoryFromIRI(ctx context.Context, repoIRI ap.IRI) error { + object, err := activitypub.FetchObject(repoIRI.String()) + if err != nil { + return err + } + 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 { + // TODO: don't call this function here + err := createRepositoryFromIRI(ctx, ticket.Context.GetLink()) + if err != nil { + return err + } + + // Construct issue + user, err := user_model.GetUserByIRI(ctx, ticket.AttributedTo.GetLink().String()) + if err != nil { + return err + } + repo, err := repo_model.GetRepositoryByIRI(ctx, 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, // TODO: 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(ctx, repo, issue, nil, nil, nil) +} + +// Create a pull request +func createPullRequest(ctx context.Context, ticket *forgefed.Ticket) error { + // TODO: don't call this function here + err := createRepositoryFromIRI(ctx, ticket.Context.GetLink()) + if err != nil { + return err + } + user, err := user_model.GetUserByIRI(ctx, ticket.AttributedTo.GetLink().String()) + 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(ctx, 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(ctx, 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 { + // Make sure repo exists + user, err := user_model.GetUserByIRI(ctx, note.AttributedTo.GetLink().String()) + if err != nil { + return err + } + + username, reponame, idx, err := activitypub.TicketIRIToName(note.Context.GetLink()) + if err != nil { + return err + } + repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, username, reponame) + if err != nil { + return err + } + issue, err := issues_model.GetIssueByIndex(repo.ID, idx) + if err != nil { + return err + } + _, err = issues_model.CreateComment(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/delete.go b/routers/api/v1/activitypub/delete.go new file mode 100644 index 000000000..c32fe3bd5 --- /dev/null +++ b/routers/api/v1/activitypub/delete.go @@ -0,0 +1,30 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package activitypub + +import ( + "context" + + user_model "code.gitea.io/gitea/models/user" + user_service "code.gitea.io/gitea/services/user" + + ap "github.com/go-ap/activitypub" +) + +// Process an incoming Delete activity +func delete(ctx context.Context, delete ap.Delete) error { + actorIRI := delete.Actor.GetLink() + objectIRI := delete.Object.GetLink() + // Make sure actor matches the object getting deleted + if actorIRI != objectIRI { + return nil + } + + // Object is the user getting deleted + objectUser, err := user_model.GetUserByIRI(ctx, objectIRI.String()) + if err != nil { + return err + } + return user_service.DeleteUser(ctx, objectUser, true) +} diff --git a/routers/api/v1/activitypub/follow.go b/routers/api/v1/activitypub/follow.go new file mode 100644 index 000000000..54e611f89 --- /dev/null +++ b/routers/api/v1/activitypub/follow.go @@ -0,0 +1,71 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package activitypub + +import ( + "context" + "errors" + "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 := user_model.GetUserByIRI(ctx, actorIRI.String()) + if err != nil { + return err + } + + // Object is the user being followed + objectIRI := follow.Object.GetLink() + objectUser, err := user_model.GetUserByIRI(ctx, objectIRI.String()) + // 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())} + accept.Object = follow + return activitypub.Send(ctx, objectUser, accept) +} + +// Process an incoming Undo follow activity +func unfollow(ctx context.Context, unfollow ap.Undo) error { + // Object contains the follow + follow, ok := unfollow.Object.(*ap.Follow) + if !ok { + return errors.New("could not cast object to follow") + } + + // Actor is the user performing the undo follow + actorIRI := follow.Actor.GetLink() + actorUser, err := user_model.GetUserByIRI(ctx, actorIRI.String()) + if err != nil { + return err + } + + // Object is the user being unfollowed + objectIRI := follow.Object.GetLink() + objectUser, err := user_model.GetUserByIRI(ctx, objectIRI.String()) + // 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 000000000..e06f8bc6f --- /dev/null +++ b/routers/api/v1/activitypub/note.go @@ -0,0 +1,73 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package activitypub + +import ( + "net/http" + + 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}/{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: noteid + // in: path + // description: ID number of the comment + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/ActivityPub" + // "204": + // "$ref": "#/responses/empty" + // "404": + // "$ref": "#/responses/notFound" + + comment, err := issues_model.GetCommentByID(ctx, ctx.ParamsInt64("noteid")) + if err != nil { + if issues_model.IsErrCommentNotExist(err) { + ctx.NotFound(err) + } else { + ctx.Error(http.StatusInternalServerError, "GetCommentByID", err) + } + return + } + + // Ensure the comment comes from the specified repository. + if comment.Issue.RepoID != ctx.Repo.Repository.ID { + ctx.Status(http.StatusNotFound) + return + } + + // Only allow comments and not events. + if comment.Type != issues_model.CommentTypeComment { + ctx.Status(http.StatusNoContent) + return + } + + note, err := activitypub.Note(ctx, 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 492930b84..04ce74c28 100644 --- a/routers/api/v1/activitypub/person.go +++ b/routers/api/v1/activitypub/person.go @@ -1,19 +1,22 @@ -// Copyright 2022 The Gitea Authors. All rights reserved. +// Copyright 2023 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT package activitypub import ( + "fmt" + "io" "net/http" "strings" - "code.gitea.io/gitea/modules/activitypub" + 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/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 @@ -22,7 +25,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 @@ -33,8 +36,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)) @@ -51,19 +54,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(ctx)), + URL: ap.IRI(ctx.ContextUser.AvatarFullLinkWithSize(ctx, 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) @@ -71,16 +77,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 @@ -89,7 +86,7 @@ func PersonInbox(ctx *context.APIContext) { // --- // summary: Send to the inbox // produces: - // - application/json + // - application/activity+json // parameters: // - name: username // in: path @@ -97,8 +94,173 @@ func PersonInbox(ctx *context.APIContext) { // type: string // required: true // responses: - // "204": + // "202": // "$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) + err = checkActivityAndKeyID(activity, keyID) + if err != nil { + ctx.ServerError("keyID does not match activity", err) + return + } + + // Process activity + switch activity.Type { + case ap.FollowType: + // Following a user + err = follow(ctx, activity) + case ap.UndoType: + // Unfollowing a user + err = unfollow(ctx, activity) + case ap.CreateType: + if activity.Object.GetType() == ap.NoteType { + // 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) + }) + } + case ap.DeleteType: + // Deleting a user + err = delete(ctx, activity) + default: + err = fmt.Errorf("unsupported ActivityStreams activity type: %s", activity.GetType()) + } + 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: + // "501": + // "$ref": "#/responses/empty" + + ctx.Status(http.StatusNotImplemented) +} + +// 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" + + listOptions := utils.GetListOptions(ctx) + users, count, err := user_model.GetUserFollowing(ctx, ctx.ContextUser, ctx.Doer, listOptions) + if err != nil { + ctx.ServerError("GetUserFollowing", err) + return + } + items := make([]string, 0) + for _, user := range users { + items = append(items, user.GetIRI()) + } + responseCollection(ctx, ctx.ContextUser.GetIRI()+"/following", listOptions, items, count) +} + +// 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" + + listOptions := utils.GetListOptions(ctx) + users, count, err := user_model.GetUserFollowers(ctx, ctx.ContextUser, ctx.Doer, listOptions) + if err != nil { + ctx.ServerError("GetUserFollowers", err) + return + } + items := make([]string, 0) + for _, user := range users { + items = append(items, user.GetIRI()) + } + responseCollection(ctx, ctx.ContextUser.GetIRI()+"/followers", listOptions, items, count) +} + +// 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" + + listOptions := utils.GetListOptions(ctx) + repos, count, err := repo_model.SearchRepository(ctx, &repo_model.SearchRepoOptions{ + ListOptions: listOptions, + Actor: ctx.Doer, + Private: ctx.IsSigned, + StarredByID: ctx.ContextUser.ID, + }) + if err != nil { + ctx.ServerError("GetUserStarred", err) + return + } + items := make([]string, 0) + for _, repo := range repos { + items = append(items, repo.GetIRI()) + } + responseCollection(ctx, ctx.ContextUser.GetIRI()+"/liked", listOptions, items, count) +} diff --git a/routers/api/v1/activitypub/repo.go b/routers/api/v1/activitypub/repo.go new file mode 100644 index 000000000..a05b62cb4 --- /dev/null +++ b/routers/api/v1/activitypub/repo.go @@ -0,0 +1,201 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package activitypub + +import ( + "fmt" + "io" + "net/http" + + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/forgefed" + "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(ctx.Repo.Owner.GetIRI()) + + 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: + // "202": + // "$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 + } + + ap.ItemTyperFunc = forgefed.GetItemByType + ap.JSONItemUnmarshal = forgefed.JSONUnmarshalerFn + ap.IsNotEmpty = 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) + err = checkActivityAndKeyID(activity, keyID) + if err != nil { + ctx.ServerError("keyID does not match activity", 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: + err = fmt.Errorf("unsupported ActivityStreams object type: %s", activity.Object.GetType()) + } + case ap.LikeType: + // Starring a repo + err = star(ctx, activity) + case ap.UndoType: + // Unstarring a repo + err = unstar(ctx, activity) + default: + err = fmt.Errorf("unsupported ActivityStreams activity type: %s", activity.GetType()) + } + if err != nil { + ctx.ServerError("Could not process activity", err) + return + } + + 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: + // "501": + // "$ref": "#/responses/empty" + + 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 2d945c27a..47851c034 100644 --- a/routers/api/v1/activitypub/reqsignature.go +++ b/routers/api/v1/activitypub/reqsignature.go @@ -1,4 +1,4 @@ -// Copyright 2022 The Gitea Authors. All rights reserved. +// Copyright 2023 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT package activitypub @@ -7,29 +7,27 @@ import ( "crypto" "crypto/x509" "encoding/pem" + "errors" "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" ) -func getPublicKeyFromResponse(b []byte, keyID *url.URL) (p crypto.PublicKey, err error) { - person := ap.PersonNew(ap.IRI(keyID.String())) +func getPublicKeyFromResponse(b []byte, keyID string) (p crypto.PublicKey, err error) { + person := ap.PersonNew(ap.IRI(keyID)) err = person.UnmarshalJSON(b) if err != nil { err = fmt.Errorf("ActivityStreams type cannot be converted to one known to have publicKey property: %w", err) return } pubKey := person.PublicKey - if pubKey.ID.String() != keyID.String() { + if pubKey.ID.String() != keyID { err = fmt.Errorf("cannot find publicKey with id: %s in %s", keyID, string(b)) return } @@ -43,49 +41,45 @@ 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 - } - 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 + return nil, "", err } - 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) - if err != nil { - return - } - ID := v.KeyId() - idIRI, err := url.Parse(ID) + v, ID, err := getKeyID(r) if err != nil { return } // 2. Fetch the public key of the other actor - b, err := fetch(idIRI) + b, err := activitypub.Fetch(ID) if err != nil { return } - pubKey, err := getPublicKeyFromResponse(b, idIRI) + pubKey, err := getPublicKeyFromResponse(b, ID) if err != nil { return } // 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 + var person ap.Person + err = person.UnmarshalJSON(b) + if err != nil { + return + } + + err = createPerson(ctx, &person) return authenticated, err } @@ -99,3 +93,29 @@ func ReqHTTPSignature() func(ctx *gitea_context.APIContext) { } } } + +// Check if the keyID matches the activity to prevent impersonation +func checkActivityAndKeyID(activity ap.Activity, keyID string) error { + if activity.Actor != nil && keyID != activity.Actor.GetLink().String()+"#main-key" { + return errors.New("actor does not match HTTP signature keyID") + } + if activity.AttributedTo != nil && keyID != activity.AttributedTo.GetLink().String()+"#main-key" { + return errors.New("attributedTo does not match HTTP signature keyID") + } + if activity.Object == nil { + return errors.New("activity does not contain object") + } + if activity.Type == ap.UndoType { + return ap.OnActivity(activity.Object, func(a *ap.Activity) error { + if a.Actor != nil && keyID != a.Actor.GetLink().String()+"#main-key" { + // TODO: This doesn't necessarily mean impersonation since the object might be created by someone else + return errors.New("actor does not match HTTP signature keyID") + } + if a.AttributedTo != nil && keyID != a.AttributedTo.GetLink().String()+"#main-key" { + return errors.New("attributedTo does not match HTTP signature keyID") + } + return nil + }) + } + return nil +} diff --git a/routers/api/v1/activitypub/response.go b/routers/api/v1/activitypub/response.go new file mode 100644 index 000000000..e02c182ac --- /dev/null +++ b/routers/api/v1/activitypub/response.go @@ -0,0 +1,65 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package activitypub + +import ( + "fmt" + "net/http" + + "code.gitea.io/gitea/models/db" + "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 a ActivityStreams Collection +func responseCollection(ctx *context.APIContext, iri string, listOptions db.ListOptions, items []string, count int64) { + collection := ap.OrderedCollectionNew(ap.IRI(iri)) + collection.First = ap.IRI(iri + "?page=1") + collection.TotalItems = uint(count) + if listOptions.Page == 0 { + response(ctx, collection) + return + } + + page := ap.OrderedCollectionPageNew(collection) + page.ID = ap.IRI(fmt.Sprintf("%s?page=%d", iri, listOptions.Page)) + if listOptions.Page > 1 { + page.Prev = ap.IRI(fmt.Sprintf("%s?page=%d", iri, listOptions.Page-1)) + } + if listOptions.Page*listOptions.PageSize < int(count) { + page.Next = ap.IRI(fmt.Sprintf("%s?page=%d", iri, listOptions.Page+1)) + } + for _, item := range items { + err := page.OrderedItems.Append(ap.IRI(item)) + if err != nil { + ctx.ServerError("Append", err) + } + } + + response(ctx, page) +} + +// 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 000000000..469941dc4 --- /dev/null +++ b/routers/api/v1/activitypub/star.go @@ -0,0 +1,45 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package activitypub + +import ( + "context" + "errors" + "strings" + + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + + 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 := user_model.GetUserByIRI(ctx, like.Actor.GetLink().String()) + if err != nil { + return + } + repo, err := repo_model.GetRepositoryByIRI(ctx, like.Object.GetLink().String()) + if err != nil || strings.Contains(repo.Name, "@") || repo.IsPrivate { + return + } + return repo_model.StarRepo(user.ID, repo.ID, true) +} + +// Process an Undo Like activity to unstar a repository +func unstar(ctx context.Context, unlike ap.Undo) (err error) { + like, ok := unlike.Object.(*ap.Like) + if !ok { + return errors.New("could not cast object to like") + } + user, err := user_model.GetUserByIRI(ctx, like.Actor.GetLink().String()) + if err != nil { + return + } + repo, err := repo_model.GetRepositoryByIRI(ctx, like.Object.GetLink().String()) + if err != nil || strings.Contains(repo.Name, "@") || repo.IsPrivate { + return + } + return repo_model.StarRepo(user.ID, repo.ID, false) +} diff --git a/routers/api/v1/activitypub/ticket.go b/routers/api/v1/activitypub/ticket.go new file mode 100644 index 000000000..ab0fe4fc7 --- /dev/null +++ b/routers/api/v1/activitypub/ticket.go @@ -0,0 +1,57 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package activitypub + +import ( + 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" + // "404": + // "$ref": "#/responses/notFound" + + issue, err := issues_model.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64("id")) + if err != nil { + if issues_model.IsErrIssueNotExist(err) { + ctx.NotFound() + } else { + ctx.ServerError("GetIssueByIndex", err) + } + return + } + + ticket, err := activitypub.Ticket(ctx, 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 464fe103e..270ce9866 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -702,12 +702,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}/{noteid}", repoAssignment(), activitypub.Note) }) } m.Get("/signing-key.gpg", misc.SigningKey) diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index 06bf06b4e..9ac1022b4 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -651,7 +651,7 @@ func CreateIssue(ctx *context.APIContext) { form.Labels = make([]int64, 0) } - if err := issue_service.NewIssue(ctx.Repo.Repository, issue, form.Labels, nil, assigneeIDs); err != nil { + if err := issue_service.NewIssue(ctx, ctx.Repo.Repository, issue, form.Labels, nil, assigneeIDs); err != nil { if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err) return diff --git a/routers/api/v1/user/follower.go b/routers/api/v1/user/follower.go index bc03b22ea..b7b8484a3 100644 --- a/routers/api/v1/user/follower.go +++ b/routers/api/v1/user/follower.go @@ -12,6 +12,7 @@ import ( api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/routers/api/v1/utils" "code.gitea.io/gitea/services/convert" + user_service "code.gitea.io/gitea/services/user" ) func responseAPIUsers(ctx *context.APIContext, users []*user_model.User) { @@ -218,7 +219,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, ctx.Doer.ID, ctx.ContextUser.ID); err != nil { ctx.Error(http.StatusInternalServerError, "FollowUser", err) return } @@ -240,7 +241,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, ctx.Doer.ID, ctx.ContextUser.ID); err != nil { ctx.Error(http.StatusInternalServerError, "UnfollowUser", err) return } diff --git a/routers/api/v1/user/star.go b/routers/api/v1/user/star.go index ad5a8bee3..02489eb9c 100644 --- a/routers/api/v1/user/star.go +++ b/routers/api/v1/user/star.go @@ -16,6 +16,7 @@ import ( api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/routers/api/v1/utils" "code.gitea.io/gitea/services/convert" + repo_service "code.gitea.io/gitea/services/repository" ) // getStarredRepos returns the repos that the user with the specified userID has @@ -151,7 +152,7 @@ func Star(ctx *context.APIContext) { // "204": // "$ref": "#/responses/empty" - err := repo_model.StarRepo(ctx.Doer.ID, ctx.Repo.Repository.ID, true) + err := repo_service.StarRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, true) if err != nil { ctx.Error(http.StatusInternalServerError, "StarRepo", err) return @@ -179,7 +180,7 @@ func Unstar(ctx *context.APIContext) { // "204": // "$ref": "#/responses/empty" - err := repo_model.StarRepo(ctx.Doer.ID, ctx.Repo.Repository.ID, false) + err := repo_service.StarRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, false) if err != nil { ctx.Error(http.StatusInternalServerError, "StarRepo", err) return diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index 3715320f1..f937d93d0 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -1133,7 +1133,7 @@ func NewIssuePost(ctx *context.Context) { Ref: form.Ref, } - if err := issue_service.NewIssue(repo, issue, labelIDs, attachments, assigneeIDs); err != nil { + if err := issue_service.NewIssue(ctx, repo, issue, labelIDs, attachments, assigneeIDs); err != nil { if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) { ctx.Error(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error()) return diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go index b4e7b5a46..d72fd39f9 100644 --- a/routers/web/repo/repo.go +++ b/routers/web/repo/repo.go @@ -287,9 +287,9 @@ func Action(ctx *context.Context) { case "unwatch": err = repo_model.WatchRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, false) case "star": - err = repo_model.StarRepo(ctx.Doer.ID, ctx.Repo.Repository.ID, true) + err = repo_service.StarRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, true) case "unstar": - err = repo_model.StarRepo(ctx.Doer.ID, ctx.Repo.Repository.ID, false) + err = repo_service.StarRepo(ctx, ctx.Doer.ID, ctx.Repo.Repository.ID, false) case "accept_transfer": err = acceptOrRejectRepoTransfer(ctx, true) case "reject_transfer": diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go index f4d458c04..e7f200aa8 100644 --- a/routers/web/user/profile.go +++ b/routers/web/user/profile.go @@ -22,6 +22,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 @@ -318,9 +319,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, ctx.Doer.ID, ctx.ContextUser.ID) case "unfollow": - err = user_model.UnfollowUser(ctx.Doer.ID, ctx.ContextUser.ID) + err = user_service.UnfollowUser(ctx, ctx.Doer.ID, ctx.ContextUser.ID) } if err != nil { diff --git a/routers/web/webfinger.go b/routers/web/webfinger.go index a8e816d7b..c5ebce555 100644 --- a/routers/web/webfinger.go +++ b/routers/web/webfinger.go @@ -13,25 +13,11 @@ import ( "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/activitypub" ) // https://datatracker.ietf.org/doc/html/draft-ietf-appsawg-webfinger-14#section-4.4 -type webfingerJRD struct { - Subject string `json:"subject,omitempty"` - Aliases []string `json:"aliases,omitempty"` - Properties map[string]interface{} `json:"properties,omitempty"` - Links []*webfingerLink `json:"links,omitempty"` -} - -type webfingerLink struct { - Rel string `json:"rel,omitempty"` - Type string `json:"type,omitempty"` - Href string `json:"href,omitempty"` - Titles map[string]string `json:"titles,omitempty"` - Properties map[string]interface{} `json:"properties,omitempty"` -} - // WebfingerQuery returns information about a resource // https://datatracker.ietf.org/doc/html/rfc7565 func WebfingerQuery(ctx *context.Context) { @@ -64,6 +50,8 @@ func WebfingerQuery(ctx *context.Context) { if u != nil && u.KeepEmailPrivate { err = user_model.ErrUserNotExist{} } + case "https": + u, err = user_model.GetUserByIRI(ctx, ctx.FormString("resource")) default: ctx.Error(http.StatusBadRequest) return @@ -91,7 +79,7 @@ func WebfingerQuery(ctx *context.Context) { aliases = append(aliases, fmt.Sprintf("mailto:%s", u.Email)) } - links := []*webfingerLink{ + links := []*activitypub.WebfingerLink{ { Rel: "http://webfinger.net/rel/profile-page", Type: "text/html", @@ -106,10 +94,14 @@ 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", "*") - ctx.JSON(http.StatusOK, &webfingerJRD{ + ctx.JSON(http.StatusOK, &activitypub.WebfingerJRD{ Subject: fmt.Sprintf("acct:%s@%s", url.QueryEscape(u.Name), appURL.Host), Aliases: aliases, Links: links, diff --git a/services/activitypub/activities.go b/services/activitypub/activities.go new file mode 100644 index 000000000..617c38ae4 --- /dev/null +++ b/services/activitypub/activities.go @@ -0,0 +1,62 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package activitypub + +import ( + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/forgefed" + + ap "github.com/go-ap/activitypub" +) + +// Create Follow activity +func Follow(actorUser, followUser *user_model.User) *ap.Follow { + return &ap.Follow{ + Type: ap.FollowType, + Actor: ap.PersonNew(ap.IRI(actorUser.GetIRI())), + Object: ap.PersonNew(ap.IRI(followUser.GetIRI())), + To: ap.ItemCollection{ap.IRI(followUser.GetIRI())}, + } +} + +// Create Undo Follow activity +func Unfollow(actorUser, followUser *user_model.User) *ap.Undo { + return &ap.Undo{ + Type: ap.UndoType, + Actor: ap.PersonNew(ap.IRI(actorUser.GetIRI())), + Object: Follow(actorUser, followUser), + To: ap.ItemCollection{ap.IRI(followUser.GetIRI())}, + } +} + +// Create Like activity +func Star(user *user_model.User, repo *repo_model.Repository) *ap.Like { + return &ap.Like{ + Type: ap.LikeType, + Actor: ap.PersonNew(ap.IRI(user.GetIRI())), + Object: forgefed.RepositoryNew(ap.IRI(repo.GetIRI())), + To: ap.ItemCollection{ap.IRI(repo.GetIRI())}, + } +} + +// Create Undo Like activity +func Unstar(user *user_model.User, repo *repo_model.Repository) *ap.Undo { + return &ap.Undo{ + Type: ap.UndoType, + Actor: ap.PersonNew(ap.IRI(user.GetIRI())), + Object: Star(user, repo), + To: ap.ItemCollection{ap.IRI(repo.GetIRI())}, + } +} + +// Create Create activity +func Create(user *user_model.User, object ap.ObjectOrLink, to string) *ap.Create { + return &ap.Create{ + Type: ap.CreateType, + Actor: ap.PersonNew(ap.IRI(user.GetIRI())), + Object: object, + To: ap.ItemCollection{ap.IRI(to)}, + } +} diff --git a/modules/activitypub/client.go b/services/activitypub/client.go index ed5c9990d..ed5c9990d 100644 --- a/modules/activitypub/client.go +++ b/services/activitypub/client.go diff --git a/modules/activitypub/client_test.go b/services/activitypub/client_test.go index 0ab512c5b..0ab512c5b 100644 --- a/modules/activitypub/client_test.go +++ b/services/activitypub/client_test.go diff --git a/services/activitypub/iri.go b/services/activitypub/iri.go new file mode 100644 index 000000000..81bf8b85d --- /dev/null +++ b/services/activitypub/iri.go @@ -0,0 +1,55 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package activitypub + +import ( + "errors" + "strconv" + "strings" + + "code.gitea.io/gitea/modules/setting" + + ap "github.com/go-ap/activitypub" +) + +// 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/main_test.go b/services/activitypub/main_test.go index 15399ca38..15399ca38 100644 --- a/modules/activitypub/main_test.go +++ b/services/activitypub/main_test.go diff --git a/services/activitypub/objects.go b/services/activitypub/objects.go new file mode 100644 index 000000000..bbd11e688 --- /dev/null +++ b/services/activitypub/objects.go @@ -0,0 +1,84 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package activitypub + +import ( + "context" + "strconv" + + 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(ctx context.Context, comment *issues_model.Comment) (*ap.Note, error) { + err := comment.LoadPoster(ctx) + if err != nil { + return nil, err + } + err = comment.LoadIssue(ctx) + if err != nil { + return nil, err + } + note := ap.Note{ + Type: ap.NoteType, + ID: ap.IRI(comment.GetIRI(ctx)), + AttributedTo: ap.IRI(comment.Poster.GetIRI()), + Context: ap.IRI(comment.Issue.GetIRI(ctx)), + 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(ctx context.Context, issue *issues_model.Issue) (*forgefed.Ticket, error) { + iri := issue.GetIRI(ctx) + 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(ctx) + if err != nil { + return nil, err + } + ticket.Context = ap.IRI(issue.Repo.GetIRI()) + + err = issue.LoadPoster(ctx) + 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 000000000..934852ac7 --- /dev/null +++ b/services/activitypub/transport.go @@ -0,0 +1,101 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package activitypub + +import ( + "context" + "fmt" + "io" + "net/http" + + "code.gitea.io/gitea/models/auth" + "code.gitea.io/gitea/models/db" + 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 URL as binary +func Fetch(iri string) (b []byte, err error) { + req := httplib.NewRequest(iri, 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 +} + +// Fetch a remote ActivityStreams object as an object +func FetchObject(iri string) (ap.ObjectOrLink, error) { + resp, err := Fetch(iri) + if err != nil { + return nil, err + } + ap.ItemTyperFunc = forgefed.GetItemByType + ap.JSONItemUnmarshal = forgefed.JSONUnmarshalerFn + ap.IsNotEmpty = forgefed.NotEmpty + return ap.UnmarshalJSON(resp) +} + +// Send an activity +func Send(ctx context.Context, 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 + } + + // Construt list of recipients + recipients := []string{} + for _, to := range activity.To { + if to.GetLink().String() == user.GetIRI()+"/followers" { + followers, count, err := user_model.GetUserFollowers(ctx, user, user, db.ListOptions{}) + if err != nil { + return err + } + for i := int64(0); i < count; i++ { + if followers[i].LoginType == auth.Federated { + recipients = append(recipients, followers[i].GetIRI()) + } + } + } else { + recipients = append(recipients, to.GetLink().String()) + } + } + + // Send out activity to recipients + for _, recipient := range recipients { + client, err := NewClient(user, user.GetIRI()+"#main-key") + if err != nil { + return err + } + resp, err := client.Post(binary, recipient) + if err != nil { + return err + } + respBody, err := io.ReadAll(io.LimitReader(resp.Body, setting.Federation.MaxSize)) + if err != nil { + return err + } + log.Trace("Response from sending activity", string(respBody)) + } + return nil +} diff --git a/modules/activitypub/user_settings.go b/services/activitypub/user_settings.go index 2d156c17e..2d156c17e 100644 --- a/modules/activitypub/user_settings.go +++ b/services/activitypub/user_settings.go diff --git a/modules/activitypub/user_settings_test.go b/services/activitypub/user_settings_test.go index 78ebf8e82..78ebf8e82 100644 --- a/modules/activitypub/user_settings_test.go +++ b/services/activitypub/user_settings_test.go diff --git a/services/activitypub/webfinger.go b/services/activitypub/webfinger.go new file mode 100644 index 000000000..e9183be62 --- /dev/null +++ b/services/activitypub/webfinger.go @@ -0,0 +1,20 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package activitypub + +type WebfingerJRD struct { + Subject string `json:"subject,omitempty"` + Aliases []string `json:"aliases,omitempty"` + Properties map[string]interface{} `json:"properties,omitempty"` + Links []*WebfingerLink `json:"links,omitempty"` +} + +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"` +} diff --git a/services/issue/comments.go b/services/issue/comments.go index 1323fb47a..0aadf9944 100644 --- a/services/issue/comments.go +++ b/services/issue/comments.go @@ -6,6 +6,7 @@ package issue import ( "context" "fmt" + "strings" "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" @@ -13,6 +14,7 @@ import ( 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" ) // CreateComment creates comment of issue or commit. @@ -78,6 +80,19 @@ func CreateIssueComment(ctx context.Context, doer *user_model.User, repo *repo_m return nil, err } + if strings.Contains(repo.OwnerName, "@") { + // Federated comment + note, err := activitypub.Note(ctx, comment) + if err != nil { + return nil, err + } + create := activitypub.Create(doer, note, repo.GetIRI()) + err = activitypub.Send(ctx, doer, create) + if err != nil { + return nil, err + } + } + mentions, err := issues_model.FindAndUpdateIssueMentions(ctx, issue, doer, comment.Content) if err != nil { return nil, err diff --git a/services/issue/issue.go b/services/issue/issue.go index b91ee4fc1..f9ca4b9ed 100644 --- a/services/issue/issue.go +++ b/services/issue/issue.go @@ -4,7 +4,9 @@ package issue import ( + "context" "fmt" + "strings" activities_model "code.gitea.io/gitea/models/activities" "code.gitea.io/gitea/models/db" @@ -17,14 +19,32 @@ import ( "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/notification" "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/services/activitypub" ) // NewIssue creates new issue with labels for repository. -func NewIssue(repo *repo_model.Repository, issue *issues_model.Issue, labelIDs []int64, uuids []string, assigneeIDs []int64) error { +func NewIssue(ctx context.Context, repo *repo_model.Repository, issue *issues_model.Issue, labelIDs []int64, uuids []string, assigneeIDs []int64) error { if err := issues_model.NewIssue(repo, issue, labelIDs, uuids); err != nil { return err } + if strings.Contains(repo.OwnerName, "@") { + // Federated issue + ticket, err := activitypub.Ticket(db.DefaultContext, issue) + if err != nil { + return err + } + err = issue.LoadPoster(db.DefaultContext) + if err != nil { + return err + } + create := activitypub.Create(issue.Poster, ticket, repo.GetIRI()) + err = activitypub.Send(ctx, 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 000000000..65d8efc8c --- /dev/null +++ b/services/repository/activitypub.go @@ -0,0 +1,45 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +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)} + 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(ctx, user, &create) +} diff --git a/services/repository/star.go b/services/repository/star.go new file mode 100644 index 000000000..01b3db7b2 --- /dev/null +++ b/services/repository/star.go @@ -0,0 +1,71 @@ +// Copyright 2023 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repository + +import ( + "context" + + "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/services/activitypub" + + ap "github.com/go-ap/activitypub" +) + +// StarRepo or unstar repository. +func StarRepo(ctx context.Context, userID, repoID int64, star bool) error { + repo, err := repo_model.GetRepositoryByID(ctx, repoID) + if err != nil { + return err + } + err = repo.LoadOwner(ctx) + if err != nil { + return err + } + if repo.Owner.LoginType == auth.Federated { + // Federated repo + user, err := user_model.GetUserByID(ctx, userID) + if err != nil { + return err + } + var activity *ap.Activity + if star { + activity = activitypub.Star(user, repo) + } else { + activity = activitypub.Unstar(user, repo) + } + err = activitypub.Send(ctx, user, activity) + if err != nil { + return err + } + } + err = repo_model.StarRepo(userID, repoID, star) + if err != nil { + return err + } + user, err := user_model.GetUserByID(ctx, userID) + if err != nil { + return err + } + + note := ap.Note{ + Type: ap.NoteType, + ID: ap.IRI(repo.GetIRI()), // TODO: serve the note at an API endpoint + AttributedTo: ap.IRI(user.GetIRI()), + To: ap.ItemCollection{ap.IRI("https://www.w3.org/ns/activitystreams#Public")}, + } + note.Content = ap.NaturalLanguageValuesNew() + err = note.Content.Set("en", ap.Content(user.Name+" starred <a href=\""+repo.HTMLURL()+"\">"+repo.FullName()+"</a>")) + if err != nil { + return err + } + create := ap.Create{ + Type: ap.CreateType, + Actor: ap.PersonNew(ap.IRI(user.GetIRI())), + Object: note, + To: ap.ItemCollection{ap.IRI(user.GetIRI() + "/followers")}, + } + return activitypub.Send(ctx, user, &create) +} diff --git a/services/user/user.go b/services/user/user.go index f0b8fe1c3..cba8261b5 100644 --- a/services/user/user.go +++ b/services/user/user.go @@ -12,6 +12,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" @@ -24,6 +25,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" ) @@ -274,3 +276,53 @@ func DeleteAvatar(u *user_model.User) error { } return nil } + +// FollowUser marks someone be another's follower. +func FollowUser(ctx context.Context, userID, followID int64) (err error) { + if userID == followID || user_model.IsFollowing(userID, followID) { + return nil + } + + followUser, err := user_model.GetUserByID(ctx, followID) + if err != nil { + return + } + if followUser.LoginType == auth.Federated { + // Following remote user + actorUser, err := user_model.GetUserByID(ctx, userID) + if err != nil { + return err + } + err = activitypub.Send(ctx, 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(ctx context.Context, userID, followID int64) (err error) { + if userID == followID || !user_model.IsFollowing(userID, followID) { + return nil + } + + followUser, err := user_model.GetUserByID(ctx, followID) + if err != nil { + return + } + if followUser.LoginType == auth.Federated { + // Unfollowing remote user + actorUser, err := user_model.GetUserByID(ctx, userID) + if err != nil { + return err + } + err = activitypub.Send(ctx, 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 a3fde59b7..f5e9d0771 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -23,10 +23,231 @@ }, "basePath": "{{AppSubUrl | JSEscape | Safe}}/api/v1", "paths": { + "/activitypub/note/{username}/{reponame}/{noteid}": { + "get": { + "produces": [ + "application/activity+json" + ], + "tags": [ + "activitypub" + ], + "summary": "Returns the Note object for a comment to an issue or PR", + "operationId": "activitypubNote", + "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 comment", + "name": "noteid", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/ActivityPub" + }, + "204": { + "$ref": "#/responses/empty" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/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": { + "202": { + "$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": { + "501": { + "$ref": "#/responses/empty" + } + } + } + }, + "/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" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/activitypub/user/{username}": { "get": { "produces": [ - "application/json" + "application/activity+json" ], "tags": [ "activitypub" @@ -49,10 +270,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" @@ -69,7 +342,33 @@ } ], "responses": { - "204": { + "202": { + "$ref": "#/responses/empty" + } + } + } + }, + "/activitypub/user/{username}/outbox": { + "get": { + "produces": [ + "application/activity+json" + ], + "tags": [ + "activitypub" + ], + "summary": "Returns the Outbox OrderedCollection", + "operationId": "activitypubPersonOutbox", + "parameters": [ + { + "type": "string", + "description": "username of the user", + "name": "username", + "in": "path", + "required": true + } + ], + "responses": { + "501": { "$ref": "#/responses/empty" } } diff --git a/tests/integration/api_activitypub_person_test.go b/tests/integration/api_activitypub_person_test.go index dadc417aa..434143194 100644 --- a/tests/integration/api_activitypub_person_test.go +++ b/tests/integration/api_activitypub_person_test.go @@ -12,9 +12,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" @@ -41,10 +41,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) |