Compare commits

...
This repository has been archived on 2024-01-04. You can view files and clone it, but cannot push or open issues or pull requests.

2 Commits

Author SHA1 Message Date
Anthony Wang 67c02096c5
A bunch of super hacky code 2023-03-16 20:48:57 +00:00
Anthony Wang 0fe1a26298
Copy federation implementation from forgejo-federation branch 2023-03-16 01:45:24 +00:00
58 changed files with 3079 additions and 107 deletions

47
FEDERATION.md Normal file
View File

@ -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.

View File

@ -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.
;;

4
go.mod
View File

@ -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

4
go.sum
View File

@ -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=

View File

@ -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)

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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)
}

105
modules/forgefed/commit.go Normal file
View File

@ -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)
}

View File

@ -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)
}

110
modules/forgefed/push.go Normal file
View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}
})
}
}

133
modules/forgefed/ticket.go Normal file
View File

@ -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)
}

View File

@ -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"},

View File

@ -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
)

View File

@ -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)
}

View File

@ -0,0 +1,329 @@
// Copyright 2023 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package activitypub
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/forgefed"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/activitypub"
issue_service "code.gitea.io/gitea/services/issue"
"code.gitea.io/gitea/services/migrations"
pull_service "code.gitea.io/gitea/services/pull"
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 err == nil {
// User already exists
return nil
} else if !user_model.IsErrUserNotExist(err) {
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 err == nil {
return nil
} else if !repo_model.IsErrRepoNotExist(err) {
return err
}
iri := repository.GetLink().String()
iriSplit := strings.Split(iri, "/")
username, reponame, instance := iriSplit[len(iriSplit)-2], iriSplit[len(iriSplit)-1], iriSplit[2]
repo, err := migrations.MigrateRepository(ctx, user, username+"@"+instance, migrations.MigrateOptions{
CloneAddr: "https://" + instance + "/" + username + "/" + reponame + ".git",
RepoName: reponame,
}, nil)
if err != nil {
return err
}
ctx, committer, err := db.TxContext(db.DefaultContext)
if err != nil {
return err
}
defer committer.Close()
if _, err := db.Exec(ctx, "UPDATE `repository` SET original_url = ? WHERE id = ?", iri, repo.ID); err != nil {
return err
}
if repository.ForkedFrom != nil {
base_repo, err := repo_model.GetRepositoryByIRI(ctx, repository.ForkedFrom.GetLink().String())
if err != nil {
return err
}
if _, err := db.Exec(ctx, "UPDATE `repository` SET fork_id = ? WHERE id = ?", base_repo.ID, repo.ID); err != nil {
return err
}
if _, err := db.Exec(ctx, "UPDATE `repository` SET is_fork = true WHERE id = ?", repo.ID); err != nil {
return err
}
}
return committer.Commit()
}
// 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 {
fmt.Println("HIIIIIIIIIIII")
fmt.Println(ticket.Context)
// 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
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -0,0 +1,154 @@
// 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/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.DefaultNaturalLanguageValue(ctx.Repo.Repository.Name)
repo.AttributedTo = ap.IRI(ctx.Repo.Owner.GetIRI())
repo.Summary = ap.DefaultNaturalLanguageValue(ctx.Repo.Repository.Description)
repo.Inbox = ap.IRI(iri + "/inbox")
repo.Outbox = ap.IRI(iri + "/outbox")
repo.Followers = ap.IRI(iri + "/followers")
repo.Team = ap.IRI(iri + "/team")
if ctx.Repo.Repository.IsFork {
_ = ctx.Repo.Repository.GetBaseRepo(ctx)
repo.ForkedFrom = ap.IRI(ctx.Repo.Repository.BaseRepo.GetIRI())
}
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, _ := io.ReadAll(io.LimitReader(ctx.Req.Body, setting.Federation.MaxSize))
// body := []byte("{\"type\":\"Ticket\",\"context\":\"https://test.exozy.me/api/v1/activitypub/repo/test/Hello-world\",\"summary\":\"Update 'README.md'\",\"content\":\"WIIJLJk\",\"attributedTo\":\"https://test.exozy.me/api/v1/activitypub/user/test\",\"isResolved\":\"false\",\"origin\":\"https://test.exozy.me/branch/test/Hello-world/main\",\"target\":\"https://git.exozy.me/branch/a/Hello-world/main\",\"name\":\"#1\"}")
s := string(body)
fmt.Println(s)
// HUGE HACK
// VERY BAD IDEA
x := strings.Index(s, "ct\":{")
body = []byte(strings.Replace(s[x+4:len(s)-1], "Object", "Ticket", 1))
fmt.Println(string(body))
var ticket forgefed.Ticket
fmt.Println(ticket)
err := ticket.UnmarshalJSON(body)
if err != nil {
ctx.ServerError("UnmarshalJSON", err)
return
}
fmt.Println(ticket)
fmt.Println(createTicket(ctx, &ticket))
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)
}

View File

@ -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
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
err = fmt.Errorf("url IRI fetch [%s] failed with status (%d): %s", iri, resp.StatusCode, resp.Status)
return
}
b, err = io.ReadAll(io.LimitReader(resp.Body, setting.Federation.MaxSize))
return b, err
return v, v.KeyId(), nil
}
func verifyHTTPSignatures(ctx *gitea_context.APIContext) (authenticated bool, err error) {
r := ctx.Req
// 1. Figure out what key we need to verify
v, err := httpsig.NewVerifier(r)
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
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -113,11 +113,12 @@ func getForkRepository(ctx *context.Context) *repo_model.Repository {
return nil
}
if forkRepo.IsEmpty {
// NOTE: This can be uncommented, but keep in mind this could cause problems in the future with F3
/*if forkRepo.IsEmpty {
log.Trace("Empty repository %-v", forkRepo)
ctx.NotFound("getForkRepository", nil)
return nil
}
}*/
if err := forkRepo.LoadOwner(ctx); err != nil {
ctx.ServerError("LoadOwner", err)

View File

@ -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":

View File

@ -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 {

View File

@ -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,

View File

@ -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)},
}
}

View File

@ -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
}

View File

@ -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 &note, 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
}

View File

@ -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()+"/inbox")
}
}
// 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
}

View File

@ -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"`
}

View File

@ -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

View File

@ -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

View File

@ -18,6 +18,7 @@ import (
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/forgefed"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/json"
@ -28,7 +29,10 @@ import (
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/sync"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/activitypub"
issue_service "code.gitea.io/gitea/services/issue"
ap "github.com/go-ap/activitypub"
)
// TODO: use clustered lock (unique queue? or *abuse* cache)
@ -125,6 +129,23 @@ func NewPullRequest(ctx context.Context, repo *repo_model.Repository, pull *issu
_, _ = issue_service.CreateComment(ops)
}
if strings.Contains(repo.OwnerName, "@") {
fmt.Println("HEEEEEEEEEEEEEEYYYYY")
ticket := forgefed.TicketNew()
ticket.AttributedTo = ap.IRI(pull.Poster.GetIRI())
head_repo, _ := repo_model.GetRepositoryByID(ctx, pr.HeadRepoID)
ticket.Context = ap.IRI(head_repo.GetIRI())
ticket.Origin = ap.IRI(head_repo.GetIRI()+"/"+pr.BaseBranch)
ticket.Target = ap.IRI(repo.GetIRI()+"/"+pr.HeadBranch)
ticket.Summary = ap.DefaultNaturalLanguageValue(pull.Title)
ticket.Content = ap.DefaultNaturalLanguageValue(pull.Content)
ticket.Name = ap.DefaultNaturalLanguageValue("#1")
create := activitypub.Create(pull.Poster, ticket, repo.GetIRI())
fmt.Println(create)
fmt.Println("AAAAAAAA", repo.GetIRI())
activitypub.Send(ctx, pull.Poster, create)
}
return nil
}

View File

@ -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)
}

View File

@ -58,6 +58,36 @@ func ForkRepository(ctx context.Context, doer, owner *user_model.User, opts Fork
}
}
/*if strings.Contains(opts.BaseRepo.OwnerName, "@") {
// TODO: This is a huge hack
// The current IRI format sucks
iri := opts.BaseRepo.GetIRI()
fmt.Println(iri)
iriSplit := strings.Split(iri, "/")
username, instance := iriSplit[len(iriSplit)-2], iriSplit[2]
fmt.Println("https://" + instance + "/" + username + "/" + opts.Name + ".git")
repo, err := migrations.MigrateRepository(ctx, doer, owner.Name, migrations.MigrateOptions{
CloneAddr: "https://" + instance + "/" + username + "/" + opts.Name + ".git",
RepoName: opts.Name,
}, nil)
if err != nil {
return nil, err
}
// Really hacky stuff
ctx, committer, err := db.TxContext(db.DefaultContext)
if err != nil {
return nil, err
}
defer committer.Close()
if _, err := db.Exec(ctx, "UPDATE `repository` SET is_fork = true WHERE id = ?", repo.ID); err != nil {
return nil, err
}
if _, err := db.Exec(ctx, "UPDATE `repository` SET fork_id = ? WHERE id = ?", opts.BaseRepo.ID, repo.ID); err != nil {
return nil, err
}
return repo, committer.Commit()
}*/
forkedRepo, err := repo_model.GetUserFork(ctx, opts.BaseRepo.ID, owner.ID)
if err != nil {
return nil, err

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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"
}
}

View File

@ -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)