Add Person and Repository ActivityPub endpoints

This commit is contained in:
Anthony Wang 2022-06-19 10:39:22 -05:00
parent 05a74e6e22
commit d12fd434ba
Signed by: a
GPG key ID: BC96B00AEC5F2D76
16 changed files with 1028 additions and 47 deletions

2
go.mod
View file

@ -85,6 +85,7 @@ require (
github.com/tstranex/u2f v1.0.0
github.com/unrolled/render v1.4.1
github.com/urfave/cli v1.22.9
github.com/valyala/fastjson v1.6.3
github.com/xanzy/go-gitlab v0.64.0
github.com/yohcop/openid-go v1.0.0
github.com/yuin/goldmark v1.4.12
@ -256,7 +257,6 @@ require (
github.com/toqueteos/webbrowser v1.2.0 // indirect
github.com/ulikunitz/xz v0.5.10 // indirect
github.com/unknwon/com v1.0.1 // indirect
github.com/valyala/fastjson v1.6.3 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xanzy/ssh-agent v0.3.1 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect

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

@ -17,7 +17,7 @@ var (
ErrNameEmpty = errors.New("Name is empty")
// AlphaDashDotPattern characters prohibited in a user name (anything except A-Za-z0-9_.-)
AlphaDashDotPattern = regexp.MustCompile(`[^\w-\.]`)
AlphaDashDotPattern = regexp.MustCompile(`[^\w-\.@]`)
)
// ErrNameReserved represents a "reserved name" error.

View file

@ -0,0 +1,71 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package forgefed
import (
ap "github.com/go-ap/activitypub"
"github.com/valyala/fastjson"
)
const (
RepositoryType ap.ActivityVocabularyType = "Repository"
)
type Repository struct {
ap.Actor
// Team Collection of actors who have management/push access to the repository
Team ap.Item `jsonld:"team,omitempty"`
// Forks OrderedCollection of repositories that are forks of this repository
Forks ap.Item `jsonld:"forks,omitempty"`
}
// GetItemByType instantiates a new Repository object if the type matches
// otherwise it defaults to existing activitypub package typer function.
func GetItemByType(typ ap.ActivityVocabularyType) (ap.Item, error) {
if typ == RepositoryType {
return RepositoryNew(""), nil
}
return ap.GetItemByType(typ)
}
// RepositoryNew initializes a Repository type actor
func RepositoryNew(id ap.ID) *Repository {
a := ap.ActorNew(id, RepositoryType)
o := Repository{Actor: *a}
o.Type = RepositoryType
return &o
}
func (r Repository) MarshalJSON() ([]byte, error) {
b, err := r.Actor.MarshalJSON()
if len(b) == 0 || err != nil {
return make([]byte, 0), err
}
b = b[:len(b)-1]
if r.Team != nil {
ap.WriteItemJSONProp(&b, "team", r.Team)
}
if r.Forks != nil {
ap.WriteItemJSONProp(&b, "forks", r.Forks)
}
ap.Write(&b, '}')
return b, nil
}
func (r *Repository) UnmarshalJSON(data []byte) error {
p := fastjson.Parser{}
val, err := p.ParseBytes(data)
if err != nil {
return err
}
r.Team = ap.JSONGetItem(val, "team")
r.Forks = ap.JSONGetItem(val, "forks")
return ap.OnActor(&r.Actor, func(a *ap.Actor) error {
return ap.LoadActor(val, a)
})
}

View file

@ -0,0 +1,167 @@
package forgefed
import (
"encoding/json"
"fmt"
"reflect"
"testing"
ap "github.com/go-ap/activitypub"
)
func Test_GetItemByType(t *testing.T) {
type testtt struct {
typ ap.ActivityVocabularyType
want ap.Item
wantErr error
}
tests := map[string]testtt{
"invalid type": {
typ: ap.ActivityVocabularyType("invalidtype"),
wantErr: fmt.Errorf("empty ActivityStreams type"), // TODO(marius): this error message needs to be improved in go-ap/activitypub
},
"Repository": {
typ: RepositoryType,
want: new(Repository),
},
"Person - fall back": {
typ: ap.PersonType,
want: new(ap.Person),
},
"Question - fall back": {
typ: ap.QuestionType,
want: new(ap.Question),
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
maybeRepository, err := GetItemByType(tt.typ)
if !reflect.DeepEqual(tt.wantErr, err) {
t.Errorf("GetItemByType() error = \"%+v\", wantErr = \"%+v\" when getting Item for type %q", tt.wantErr, err, tt.typ)
}
if reflect.TypeOf(tt.want) != reflect.TypeOf(maybeRepository) {
t.Errorf("Invalid type received %T, expected %T", maybeRepository, tt.want)
}
})
}
}
func Test_RepositoryMarshalJSON(t *testing.T) {
type testPair struct {
item Repository
want []byte
wantErr error
}
tests := map[string]testPair{
"empty": {
item: Repository{},
want: nil,
},
"with ID": {
item: Repository{
Actor: ap.Actor{
ID: "https://example.com/1",
},
Team: nil,
},
want: []byte(`{"id":"https://example.com/1"}`),
},
"with Team as IRI": {
item: Repository{
Team: ap.IRI("https://example.com/1"),
},
want: []byte(`{"team":"https://example.com/1"}`),
},
"with Team as IRIs": {
item: Repository{
Team: ap.ItemCollection{
ap.IRI("https://example.com/1"),
ap.IRI("https://example.com/2"),
},
},
want: []byte(`{"team":["https://example.com/1","https://example.com/2"]}`),
},
"with Team as Object": {
item: Repository{
Team: ap.Object{ID: "https://example.com/1"},
},
want: []byte(`{"team":{"id":"https://example.com/1"}}`),
},
"with Team as slice of Objects": {
item: Repository{
Team: ap.ItemCollection{
ap.Object{ID: "https://example.com/1"},
ap.Object{ID: "https://example.com/2"},
},
},
want: []byte(`{"team":[{"id":"https://example.com/1"},{"id":"https://example.com/2"}]}`),
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
got, err := tt.item.MarshalJSON()
if (err != nil || tt.wantErr != nil) && tt.wantErr.Error() != err.Error() {
t.Errorf("MarshalJSON() error = \"%v\", wantErr \"%v\"", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("MarshalJSON() got = %q, want %q", got, tt.want)
}
})
}
}
func Test_RepositoryUnmarshalJSON(t *testing.T) {
type testPair struct {
data []byte
want *Repository
wantErr error
}
tests := map[string]testPair{
"nil": {
data: nil,
wantErr: fmt.Errorf("cannot parse JSON: %w", fmt.Errorf("cannot parse empty string; unparsed tail: %q", "")),
},
"empty": {
data: []byte{},
wantErr: fmt.Errorf("cannot parse JSON: %w", fmt.Errorf("cannot parse empty string; unparsed tail: %q", "")),
},
"with Type": {
data: []byte(`{"type":"Repository"}`),
want: &Repository{
Actor: ap.Actor{
Type: RepositoryType,
},
},
},
"with Type and ID": {
data: []byte(`{"id":"https://example.com/1","type":"Repository"}`),
want: &Repository{
Actor: ap.Actor{
ID: "https://example.com/1",
Type: RepositoryType,
},
},
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
got := new(Repository)
err := got.UnmarshalJSON(tt.data)
if (err != nil || tt.wantErr != nil) && tt.wantErr.Error() != err.Error() {
t.Errorf("UnmarshalJSON() error = \"%v\", wantErr \"%v\"", err, tt.wantErr)
return
}
if tt.want != nil && !reflect.DeepEqual(got, tt.want) {
jGot, _ := json.Marshal(got)
jWant, _ := json.Marshal(tt.want)
t.Errorf("UnmarshalJSON() got = %s, want %s", jGot, jWant)
}
})
}
}

View file

@ -0,0 +1,46 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package activitypub
import (
"context"
"strings"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
ap "github.com/go-ap/activitypub"
)
func Follow(ctx context.Context, activity ap.Follow) {
actorIRI := activity.Actor.GetID()
objectIRI := activity.Object.GetID()
actorIRISplit := strings.Split(actorIRI.String(), "/")
objectIRISplit := strings.Split(objectIRI.String(), "/")
actorName := actorIRISplit[len(actorIRISplit)-1] + "@" + actorIRISplit[2]
objectName := objectIRISplit[len(objectIRISplit)-1]
err := FederatedUserNew(actorName, actorIRI)
if err != nil {
log.Warn("Couldn't create new user", err)
}
actorUser, err := user_model.GetUserByName(ctx, actorName)
if err != nil {
log.Warn("Couldn't find actor", err)
}
objectUser, err := user_model.GetUserByName(ctx, objectName)
if err != nil {
log.Warn("Couldn't find object", err)
}
user_model.FollowUser(actorUser.ID, objectUser.ID)
accept := ap.AcceptNew(objectIRI, activity)
accept.Actor = ap.Person{ID: objectIRI}
accept.To = ap.ItemCollection{ap.IRI(actorIRI.String() + "/inbox")}
accept.Object = activity
Send(objectUser, accept)
}

View file

@ -0,0 +1,59 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package activitypub
import (
"fmt"
"io"
"net/http"
"net/url"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
ap "github.com/go-ap/activitypub"
)
func Fetch(iri *url.URL) (b []byte, err error) {
req := httplib.NewRequest(iri.String(), http.MethodGet)
req.Header("Accept", ActivityStreamsContentType)
req.Header("User-Agent", "Gitea/"+setting.AppVer)
resp, err := req.Response()
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
err = fmt.Errorf("url IRI fetch [%s] failed with status (%d): %s", iri, resp.StatusCode, resp.Status)
return
}
b, err = io.ReadAll(io.LimitReader(resp.Body, setting.Federation.MaxSize))
return
}
func Send(user *user_model.User, activity *ap.Activity) {
body, err := activity.MarshalJSON()
if err != nil {
return
}
var jsonmap map[string]interface{}
err = json.Unmarshal(body, &jsonmap)
if err != nil {
return
}
jsonmap["@context"] = "https://www.w3.org/ns/activitystreams"
body, _ = json.Marshal(jsonmap)
for _, to := range activity.To {
client, _ := NewClient(user, setting.AppURL+"api/v1/activitypub/user/"+user.Name+"#main-key")
resp, _ := client.Post(body, to.GetID().String())
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, setting.Federation.MaxSize))
log.Debug(string(respBody))
}
}

View file

@ -0,0 +1,22 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package activitypub
import (
"code.gitea.io/gitea/models/auth"
user_model "code.gitea.io/gitea/models/user"
ap "github.com/go-ap/activitypub"
)
func FederatedUserNew(name string, IRI ap.IRI) error {
user := &user_model.User{
Name: name,
Email: name,
LoginType: auth.Federated,
Website: IRI.String(),
}
return user_model.CreateUser(user)
}

View file

@ -5,16 +5,21 @@
package activitypub
import (
"io"
"net/http"
"strings"
"code.gitea.io/gitea/models"
"code.gitea.io/gitea/models/forgefed"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/activitypub"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/routers/api/v1/utils"
ap "github.com/go-ap/activitypub"
"github.com/go-ap/jsonld"
)
// Person function returns the Person actor for a user
@ -23,7 +28,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
@ -62,6 +67,11 @@ func Person(ctx *context.APIContext) {
person.Inbox = ap.IRI(link + "/inbox")
person.Outbox = ap.IRI(link + "/outbox")
person.Following = ap.IRI(link + "/following")
person.Followers = ap.IRI(link + "/followers")
person.Liked = ap.IRI(link + "/liked")
person.PublicKey.ID = ap.IRI(link + "#main-key")
person.PublicKey.Owner = ap.IRI(link)
@ -72,16 +82,7 @@ func Person(ctx *context.APIContext) {
}
person.PublicKey.PublicKeyPem = publicKeyPem
binary, err := jsonld.WithContext(jsonld.IRI(ap.ActivityBaseURI), jsonld.IRI(ap.SecurityContextURI)).Marshal(person)
if err != nil {
ctx.ServerError("MarshalJSON", err)
return
}
ctx.Resp.Header().Add("Content-Type", activitypub.ActivityStreamsContentType)
ctx.Resp.WriteHeader(http.StatusOK)
if _, err = ctx.Resp.Write(binary); err != nil {
log.Error("write to resp err: %v", err)
}
response(ctx, person)
}
// PersonInbox function handles the incoming data for a user inbox
@ -90,7 +91,7 @@ func PersonInbox(ctx *context.APIContext) {
// ---
// summary: Send to the inbox
// produces:
// - application/json
// - application/activity+json
// parameters:
// - name: username
// in: path
@ -98,9 +99,180 @@ func PersonInbox(ctx *context.APIContext) {
// type: string
// required: true
// responses:
// responses:
// "204":
// "$ref": "#/responses/empty"
body, err := io.ReadAll(io.LimitReader(ctx.Req.Body, setting.Federation.MaxSize))
if err != nil {
ctx.ServerError("Error reading request body", err)
}
var activity ap.Activity
activity.UnmarshalJSON(body)
if activity.Type == ap.FollowType {
activitypub.Follow(ctx, activity)
} else {
log.Warn("ActivityStreams type not supported", activity)
ctx.PlainText(http.StatusNotImplemented, "ActivityStreams type not supported")
return
}
ctx.Status(http.StatusNoContent)
}
// PersonOutbox function returns the user's Outbox OrderedCollection
func PersonOutbox(ctx *context.APIContext) {
// swagger:operation GET /activitypub/user/{username}/outbox activitypub activitypubPersonOutbox
// ---
// summary: Returns the Outbox OrderedCollection
// produces:
// - application/activity+json
// parameters:
// - name: username
// in: path
// description: username of the user
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/ActivityPub"
link := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ctx.Req.URL.EscapedPath(), "/")
feed, err := models.GetFeeds(ctx, models.GetFeedsOptions{
RequestedUser: ctx.ContextUser,
Actor: ctx.ContextUser,
IncludePrivate: false,
OnlyPerformedBy: true,
IncludeDeleted: false,
Date: ctx.FormString("date"),
})
if err != nil {
ctx.ServerError("Couldn't fetch outbox", err)
}
outbox := ap.OrderedCollectionNew(ap.IRI(link))
for _, action := range feed {
/*if action.OpType == ExampleType {
activity := ap.ExampleNew()
outbox.OrderedItems.Append(activity)
}*/
log.Debug(action.Content)
}
outbox.TotalItems = uint(len(outbox.OrderedItems))
response(ctx, outbox)
}
// PersonFollowing function returns the user's Following Collection
func PersonFollowing(ctx *context.APIContext) {
// swagger:operation GET /activitypub/user/{username}/following activitypub activitypubPersonFollowing
// ---
// summary: Returns the Following Collection
// produces:
// - application/activity+json
// parameters:
// - name: username
// in: path
// description: username of the user
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/ActivityPub"
link := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ctx.Req.URL.EscapedPath(), "/")
users, err := user_model.GetUserFollowing(ctx.ContextUser, utils.GetListOptions(ctx))
if err != nil {
ctx.ServerError("GetUserFollowing", err)
return
}
following := ap.OrderedCollectionNew(ap.IRI(link))
following.TotalItems = uint(len(users))
for _, user := range users {
// TODO: handle non-Federated users
person := ap.PersonNew(ap.IRI(user.Website))
following.OrderedItems.Append(person)
}
response(ctx, following)
}
// PersonFollowers function returns the user's Followers Collection
func PersonFollowers(ctx *context.APIContext) {
// swagger:operation GET /activitypub/user/{username}/followers activitypub activitypubPersonFollowers
// ---
// summary: Returns the Followers Collection
// produces:
// - application/activity+json
// parameters:
// - name: username
// in: path
// description: username of the user
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/ActivityPub"
link := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ctx.Req.URL.EscapedPath(), "/")
users, err := user_model.GetUserFollowers(ctx.ContextUser, utils.GetListOptions(ctx))
if err != nil {
ctx.ServerError("GetUserFollowers", err)
return
}
followers := ap.OrderedCollectionNew(ap.IRI(link))
followers.TotalItems = uint(len(users))
for _, user := range users {
person := ap.PersonNew(ap.IRI(user.Website))
followers.OrderedItems.Append(person)
}
response(ctx, followers)
}
// PersonLiked function returns the user's Liked Collection
func PersonLiked(ctx *context.APIContext) {
// swagger:operation GET /activitypub/user/{username}/followers activitypub activitypubPersonLiked
// ---
// summary: Returns the Liked Collection
// produces:
// - application/activity+json
// parameters:
// - name: username
// in: path
// description: username of the user
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/ActivityPub"
link := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ctx.Req.URL.EscapedPath(), "/")
repos, count, err := repo_model.SearchRepository(&repo_model.SearchRepoOptions{
Actor: ctx.Doer,
Private: ctx.IsSigned,
StarredByID: ctx.ContextUser.ID,
})
if err != nil {
ctx.ServerError("GetUserStarred", err)
return
}
liked := ap.OrderedCollectionNew(ap.IRI(link))
liked.TotalItems = uint(count)
for _, repo := range repos {
repo := forgefed.RepositoryNew(ap.IRI(strings.TrimSuffix(setting.AppURL, "/") + "/api/v1/activitypub/repo/" + repo.OwnerName + "/" + repo.Name))
liked.OrderedItems.Append(repo)
}
response(ctx, liked)
}

View file

@ -0,0 +1,198 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package activitypub
import (
"io"
"net/http"
"strings"
"code.gitea.io/gitea/models"
"code.gitea.io/gitea/models/forgefed"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/routers/api/v1/utils"
ap "github.com/go-ap/activitypub"
)
// Repo function
func Repo(ctx *context.APIContext) {
// swagger:operation GET /activitypub/repo/{username}/{reponame} activitypub activitypubRepo
// ---
// summary: Returns the repository
// produces:
// - application/activity+json
// parameters:
// - name: username
// in: path
// description: username of the user
// type: string
// required: true
// - name: reponame
// in: path
// description: name of the repository
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/ActivityPub"
link := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ctx.Req.URL.EscapedPath(), "/")
repo := forgefed.RepositoryNew(ap.IRI(link))
repo.Name = ap.NaturalLanguageValuesNew()
err := repo.Name.Set("en", ap.Content(ctx.Repo.Repository.Name))
if err != nil {
ctx.ServerError("Set Name", err)
return
}
repo.AttributedTo = ap.IRI(strings.TrimSuffix(link, "/"+ctx.Repo.Repository.Name))
repo.Summary = ap.NaturalLanguageValuesNew()
err = repo.Summary.Set("en", ap.Content(ctx.Repo.Repository.Description))
if err != nil {
ctx.ServerError("Set Description", err)
return
}
repo.Inbox = ap.IRI(link + "/inbox")
repo.Outbox = ap.IRI(link + "/outbox")
repo.Followers = ap.IRI(link + "/followers")
repo.Team = ap.IRI(link + "/team")
response(ctx, repo)
}
// RepoInbox function
func RepoInbox(ctx *context.APIContext) {
// swagger:operation POST /activitypub/repo/{username}/{reponame}/inbox activitypub activitypubRepoInbox
// ---
// summary: Send to the inbox
// produces:
// - application/activity+json
// parameters:
// - name: username
// in: path
// description: username of the user
// type: string
// required: true
// - name: reponame
// in: path
// description: name of the repository
// type: string
// required: true
// responses:
// "204":
// "$ref": "#/responses/empty"
body, err := io.ReadAll(ctx.Req.Body)
if err != nil {
ctx.ServerError("Error reading request body", err)
}
var activity ap.Activity
activity.UnmarshalJSON(body)
if activity.Type == ap.FollowType {
// activitypub.Follow(ctx, activity)
} else {
log.Warn("ActivityStreams type not supported", activity)
}
ctx.Status(http.StatusNoContent)
}
// RepoOutbox function
func RepoOutbox(ctx *context.APIContext) {
// swagger:operation GET /activitypub/repo/{username}/outbox activitypub activitypubPersonOutbox
// ---
// summary: Returns the outbox
// produces:
// - application/activity+json
// parameters:
// - name: username
// in: path
// description: username of the user
// type: string
// required: true
// - name: reponame
// in: path
// description: name of the repository
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/ActivityPub"
link := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ctx.Req.URL.EscapedPath(), "/")
feed, err := models.GetFeeds(ctx, models.GetFeedsOptions{
RequestedUser: ctx.ContextUser,
Actor: ctx.ContextUser,
IncludePrivate: false,
OnlyPerformedBy: true,
IncludeDeleted: false,
Date: ctx.FormString("date"),
})
if err != nil {
ctx.ServerError("Couldn't fetch outbox", err)
}
outbox := ap.OrderedCollectionNew(ap.IRI(link))
for _, action := range feed {
/*if action.OpType == ExampleType {
activity := ap.ExampleNew()
outbox.OrderedItems.Append(activity)
}*/
log.Debug(action.Content)
}
outbox.TotalItems = uint(len(outbox.OrderedItems))
response(ctx, outbox)
}
// RepoFollowers function
func RepoFollowers(ctx *context.APIContext) {
// swagger:operation GET /activitypub/repo/{username}/{reponame}/followers activitypub activitypubRepoFollowers
// ---
// summary: Returns the followers collection
// produces:
// - application/activity+json
// parameters:
// - name: username
// in: path
// description: username of the user
// type: string
// required: true
// - name: reponame
// in: path
// description: name of the repository
// type: string
// required: true
// responses:
// "200":
// "$ref": "#/responses/ActivityPub"
link := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ctx.Req.URL.EscapedPath(), "/")
users, err := user_model.GetUserFollowers(ctx.ContextUser, utils.GetListOptions(ctx))
if err != nil {
ctx.ServerError("GetUserFollowers", err)
return
}
followers := ap.OrderedCollectionNew(ap.IRI(link))
followers.TotalItems = uint(len(users))
for _, user := range users {
person := ap.PersonNew(ap.IRI(user.Website))
followers.OrderedItems.Append(person)
}
response(ctx, followers)
}

View file

@ -9,13 +9,11 @@ import (
"crypto/x509"
"encoding/pem"
"fmt"
"io"
"net/http"
"net/url"
"code.gitea.io/gitea/modules/activitypub"
gitea_context "code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/setting"
ap "github.com/go-ap/activitypub"
@ -44,24 +42,6 @@ func getPublicKeyFromResponse(b []byte, keyID *url.URL) (p crypto.PublicKey, err
return
}
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()
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
err = fmt.Errorf("url IRI fetch [%s] failed with status (%d): %s", iri, resp.StatusCode, resp.Status)
return
}
b, err = io.ReadAll(io.LimitReader(resp.Body, setting.Federation.MaxSize))
return
}
func verifyHTTPSignatures(ctx *gitea_context.APIContext) (authenticated bool, err error) {
r := ctx.Req
@ -76,7 +56,7 @@ func verifyHTTPSignatures(ctx *gitea_context.APIContext) (authenticated bool, er
return
}
// 2. Fetch the public key of the other actor
b, err := fetch(idIRI)
b, err := activitypub.Fetch(idIRI)
if err != nil {
return
}

View file

@ -0,0 +1,29 @@
// Copyright 2022 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package activitypub
import (
"net/http"
"code.gitea.io/gitea/modules/activitypub"
"code.gitea.io/gitea/modules/context"
"code.gitea.io/gitea/modules/log"
ap "github.com/go-ap/activitypub"
"github.com/go-ap/jsonld"
)
func response(ctx *context.APIContext, v interface{}) {
binary, err := jsonld.WithContext(jsonld.IRI(ap.ActivityBaseURI), jsonld.IRI(ap.SecurityContextURI)).Marshal(v)
if err != nil {
ctx.ServerError("Marshal", err)
return
}
ctx.Resp.Header().Add("Content-Type", activitypub.ActivityStreamsContentType)
ctx.Resp.WriteHeader(http.StatusOK)
if _, err = ctx.Resp.Write(binary); err != nil {
log.Error("write to resp err: %v", err)
}
}

View file

@ -648,7 +648,17 @@ func Routes() *web.Route {
m.Group("/user/{username}", func() {
m.Get("", activitypub.Person)
m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox)
m.Get("/outbox", activitypub.PersonOutbox)
m.Get("/following", activitypub.PersonFollowing)
m.Get("/followers", activitypub.PersonFollowers)
m.Get("/liked", activitypub.PersonLiked)
}, context_service.UserAssignmentAPI())
m.Group("/repo/{username}/{reponame}", func() {
m.Get("", activitypub.Repo)
m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.RepoInbox)
m.Get("/outbox", activitypub.RepoOutbox)
m.Get("/followers", activitypub.RepoFollowers)
}, repoAssignment())
})
}
m.Get("/signing-key.gpg", misc.SigningKey)

View file

@ -36,6 +36,12 @@ func Profile(ctx *context.Context) {
return
}
if strings.Contains(ctx.ContextUser.Name, "@") {
ctx.Resp.Header().Add("Location", ctx.ContextUser.Website)
ctx.Resp.WriteHeader(http.StatusTemporaryRedirect)
return
}
if ctx.ContextUser.IsOrganization() {
org.Home(ctx)
return

View file

@ -29,6 +29,7 @@ type webfingerLink struct {
Rel string `json:"rel,omitempty"`
Type string `json:"type,omitempty"`
Href string `json:"href,omitempty"`
Template string `json:"template,omitempty"`
Titles map[string]string `json:"titles,omitempty"`
Properties map[string]interface{} `json:"properties,omitempty"`
}
@ -107,6 +108,10 @@ func WebfingerQuery(ctx *context.Context) {
Type: "application/activity+json",
Href: appURL.String() + "api/v1/activitypub/user/" + url.PathEscape(u.Name),
},
{
Rel: "http://ostatus.org/schema/1.0/subscribe",
Template: appURL.String() + "api/v1/authorize_interaction?uri={uri}",
},
}
ctx.Resp.Header().Add("Access-Control-Allow-Origin", "*")

View file

@ -23,10 +23,142 @@
},
"basePath": "{{AppSubUrl | JSEscape | Safe}}/api/v1",
"paths": {
"/activitypub/repo/{username}/outbox": {
"get": {
"produces": [
"application/activity+json"
],
"tags": [
"activitypub"
],
"summary": "Returns the outbox",
"operationId": "activitypubPersonOutbox",
"parameters": [
{
"type": "string",
"description": "username of the user",
"name": "username",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repository",
"name": "reponame",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/ActivityPub"
}
}
}
},
"/activitypub/repo/{username}/{reponame}": {
"get": {
"produces": [
"application/activity+json"
],
"tags": [
"activitypub"
],
"summary": "Returns the repository",
"operationId": "activitypubRepo",
"parameters": [
{
"type": "string",
"description": "username of the user",
"name": "username",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repository",
"name": "reponame",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/ActivityPub"
}
}
}
},
"/activitypub/repo/{username}/{reponame}/followers": {
"get": {
"produces": [
"application/activity+json"
],
"tags": [
"activitypub"
],
"summary": "Returns the followers collection",
"operationId": "activitypubRepoFollowers",
"parameters": [
{
"type": "string",
"description": "username of the user",
"name": "username",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repository",
"name": "reponame",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/ActivityPub"
}
}
}
},
"/activitypub/repo/{username}/{reponame}/inbox": {
"post": {
"produces": [
"application/activity+json"
],
"tags": [
"activitypub"
],
"summary": "Send to the inbox",
"operationId": "activitypubRepoInbox",
"parameters": [
{
"type": "string",
"description": "username of the user",
"name": "username",
"in": "path",
"required": true
},
{
"type": "string",
"description": "name of the repository",
"name": "reponame",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"$ref": "#/responses/empty"
}
}
}
},
"/activitypub/user/{username}": {
"get": {
"produces": [
"application/json"
"application/activity+json"
],
"tags": [
"activitypub"
@ -49,10 +181,62 @@
}
}
},
"/activitypub/user/{username}/followers": {
"get": {
"produces": [
"application/activity+json"
],
"tags": [
"activitypub"
],
"summary": "Returns the Liked Collection",
"operationId": "activitypubPersonLiked",
"parameters": [
{
"type": "string",
"description": "username of the user",
"name": "username",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/ActivityPub"
}
}
}
},
"/activitypub/user/{username}/following": {
"get": {
"produces": [
"application/activity+json"
],
"tags": [
"activitypub"
],
"summary": "Returns the Following Collection",
"operationId": "activitypubPersonFollowing",
"parameters": [
{
"type": "string",
"description": "username of the user",
"name": "username",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/ActivityPub"
}
}
}
},
"/activitypub/user/{username}/inbox": {
"post": {
"produces": [
"application/json"
"application/activity+json"
],
"tags": [
"activitypub"
@ -75,6 +259,32 @@
}
}
},
"/activitypub/user/{username}/outbox": {
"get": {
"produces": [
"application/activity+json"
],
"tags": [
"activitypub"
],
"summary": "Returns the Outbox OrderedCollection",
"operationId": "activitypubPersonOutbox",
"parameters": [
{
"type": "string",
"description": "username of the user",
"name": "username",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"$ref": "#/responses/ActivityPub"
}
}
}
},
"/admin/cron": {
"get": {
"produces": [