diff options
author | Anthony Wang | 2022-06-16 16:25:17 -0500 |
---|---|---|
committer | Anthony Wang | 2022-06-16 16:25:17 -0500 |
commit | dbb1d5ba23e1fede7b505d0b1ee24bd4e9d64dd4 (patch) | |
tree | 05144ff7f0640ebe57fb2e6fec22772c6d09eea1 | |
parent | af2419c5d767c45d58ab7066ef57cd6d80a369fc (diff) |
Implement Repository actor endpoints
-rw-r--r-- | models/forgefed/forgefed.go | 26 | ||||
-rw-r--r-- | models/forgefed/repository.go | 72 | ||||
-rw-r--r-- | models/forgefed/repository_test.go | 167 | ||||
-rw-r--r-- | routers/api/v1/activitypub/person.go | 54 | ||||
-rw-r--r-- | routers/api/v1/activitypub/repo.go | 212 | ||||
-rw-r--r-- | routers/api/v1/api.go | 7 | ||||
-rw-r--r-- | templates/swagger/v1_json.tmpl | 112 |
7 files changed, 617 insertions, 33 deletions
diff --git a/models/forgefed/forgefed.go b/models/forgefed/forgefed.go deleted file mode 100644 index 53c0db646..000000000 --- a/models/forgefed/forgefed.go +++ /dev/null @@ -1,26 +0,0 @@ -// 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" -) - -const ( - RepositoryType ap.ActivityVocabularyType = "Repository" -) - -type Repository struct { - ap.Actor - // Team specifies a Collection of actors who are working on the object - Team ap.Item `jsonld:"team,omitempty"` -} - -// RepositoryNew initializes a Repository type actor -func RepositoryNew(id ap.ID) *Repository { - a := ap.ActorNew(id, RepositoryType) - o := Repository{Actor: *a} - return &o -}
\ No newline at end of file diff --git a/models/forgefed/repository.go b/models/forgefed/repository.go new file mode 100644 index 000000000..996d0edea --- /dev/null +++ b/models/forgefed/repository.go @@ -0,0 +1,72 @@ +// 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 ( + "github.com/valyala/fastjson" + + ap "github.com/go-ap/activitypub" +) + +const ( + RepositoryType ap.ActivityVocabularyType = "Repository" +) + +type Repository struct { + ap.Actor + // Team Collection of actors who have management/push access to the repository + Team ap.Item `jsonld:"team,omitempty"` + // Forks OrderedCollection of repositories that are forks of this repository + Forks ap.Item `jsonld:"forks,omitempty"` +} + +// GetItemByType instantiates a new Repository object if the type matches +// otherwise it defaults to existing activitypub package typer function. +func GetItemByType(typ ap.ActivityVocabularyType) (ap.Item, error) { + if typ == RepositoryType { + return RepositoryNew(""), nil + } + return ap.GetItemByType(typ) +} + +// RepositoryNew initializes a Repository type actor +func RepositoryNew(id ap.ID) *Repository { + a := ap.ActorNew(id, RepositoryType) + o := Repository{Actor: *a} + o.Type = RepositoryType + return &o +} + +func (r Repository) MarshalJSON() ([]byte, error) { + b, err := r.Actor.MarshalJSON() + if len(b) == 0 || err != nil { + return make([]byte, 0), err + } + + b = b[:len(b)-1] + if r.Team != nil { + ap.WriteItemJSONProp(&b, "team", r.Team) + } + if r.Forks != nil { + ap.WriteItemJSONProp(&b, "forks", r.Forks) + } + ap.Write(&b, '}') + return b, nil +} + +func (r *Repository) UnmarshalJSON(data []byte) error { + p := fastjson.Parser{} + val, err := p.ParseBytes(data) + if err != nil { + return err + } + + r.Team = ap.JSONGetItem(val, "team") + r.Forks = ap.JSONGetItem(val, "forks") + + return ap.OnActor(&r.Actor, func(a *ap.Actor) error { + return ap.LoadActor(val, a) + }) +} diff --git a/models/forgefed/repository_test.go b/models/forgefed/repository_test.go new file mode 100644 index 000000000..4bf36a66c --- /dev/null +++ b/models/forgefed/repository_test.go @@ -0,0 +1,167 @@ +package forgefed + +import ( + "encoding/json" + "fmt" + "reflect" + "testing" + + ap "github.com/go-ap/activitypub" +) + +func Test_GetItemByType(t *testing.T) { + type testtt struct { + typ ap.ActivityVocabularyType + want ap.Item + wantErr error + } + tests := map[string]testtt{ + "invalid type": { + typ: ap.ActivityVocabularyType("invalidtype"), + wantErr: fmt.Errorf("empty ActivityStreams type"), // TODO(marius): this error message needs to be improved in go-ap/activitypub + }, + "Repository": { + typ: RepositoryType, + want: new(Repository), + }, + "Person - fall back": { + typ: ap.PersonType, + want: new(ap.Person), + }, + "Question - fall back": { + typ: ap.QuestionType, + want: new(ap.Question), + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + maybeRepository, err := GetItemByType(tt.typ) + if !reflect.DeepEqual(tt.wantErr, err) { + t.Errorf("GetItemByType() error = \"%+v\", wantErr = \"%+v\" when getting Item for type %q", tt.wantErr, err, tt.typ) + } + if reflect.TypeOf(tt.want) != reflect.TypeOf(maybeRepository) { + t.Errorf("Invalid type received %T, expected %T", maybeRepository, tt.want) + } + }) + } +} + +func Test_RepositoryMarshalJSON(t *testing.T) { + type testPair struct { + item Repository + want []byte + wantErr error + } + + tests := map[string]testPair{ + "empty": { + item: Repository{}, + want: nil, + }, + "with ID": { + item: Repository{ + Actor: ap.Actor{ + ID: "https://example.com/1", + }, + Team: nil, + }, + want: []byte(`{"id":"https://example.com/1"}`), + }, + "with Team as IRI": { + item: Repository{ + Team: ap.IRI("https://example.com/1"), + }, + want: []byte(`{"team":"https://example.com/1"}`), + }, + "with Team as IRIs": { + item: Repository{ + Team: ap.ItemCollection{ + ap.IRI("https://example.com/1"), + ap.IRI("https://example.com/2"), + }, + }, + want: []byte(`{"team":["https://example.com/1","https://example.com/2"]}`), + }, + "with Team as Object": { + item: Repository{ + Team: ap.Object{ID: "https://example.com/1"}, + }, + want: []byte(`{"team":{"id":"https://example.com/1"}}`), + }, + "with Team as slice of Objects": { + item: Repository{ + Team: ap.ItemCollection{ + ap.Object{ID: "https://example.com/1"}, + ap.Object{ID: "https://example.com/2"}, + }, + }, + want: []byte(`{"team":[{"id":"https://example.com/1"},{"id":"https://example.com/2"}]}`), + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got, err := tt.item.MarshalJSON() + if (err != nil || tt.wantErr != nil) && tt.wantErr.Error() != err.Error() { + t.Errorf("MarshalJSON() error = \"%v\", wantErr \"%v\"", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("MarshalJSON() got = %q, want %q", got, tt.want) + } + }) + } +} + +func Test_RepositoryUnmarshalJSON(t *testing.T) { + type testPair struct { + data []byte + want *Repository + wantErr error + } + + tests := map[string]testPair{ + "nil": { + data: nil, + wantErr: fmt.Errorf("cannot parse JSON: %w", fmt.Errorf("cannot parse empty string; unparsed tail: %q", "")), + }, + "empty": { + data: []byte{}, + wantErr: fmt.Errorf("cannot parse JSON: %w", fmt.Errorf("cannot parse empty string; unparsed tail: %q", "")), + }, + "with Type": { + data: []byte(`{"type":"Repository"}`), + want: &Repository{ + Actor: ap.Actor{ + Type: RepositoryType, + }, + }, + }, + "with Type and ID": { + data: []byte(`{"id":"https://example.com/1","type":"Repository"}`), + want: &Repository{ + Actor: ap.Actor{ + ID: "https://example.com/1", + Type: RepositoryType, + }, + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got := new(Repository) + err := got.UnmarshalJSON(tt.data) + if (err != nil || tt.wantErr != nil) && tt.wantErr.Error() != err.Error() { + t.Errorf("UnmarshalJSON() error = \"%v\", wantErr \"%v\"", err, tt.wantErr) + return + } + if tt.want != nil && !reflect.DeepEqual(got, tt.want) { + jGot, _ := json.Marshal(got) + jWant, _ := json.Marshal(tt.want) + t.Errorf("UnmarshalJSON() got = %s, want %s", jGot, jWant) + } + }) + } +} diff --git a/routers/api/v1/activitypub/person.go b/routers/api/v1/activitypub/person.go index 8202ee15e..700d9ac0d 100644 --- a/routers/api/v1/activitypub/person.go +++ b/routers/api/v1/activitypub/person.go @@ -10,6 +10,8 @@ import ( "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" @@ -82,7 +84,7 @@ func Person(ctx *context.APIContext) { binary, err := person.MarshalJSON() if err != nil { - ctx.Error(http.StatusInternalServerError, "Serialize", err) + ctx.Error(http.StatusInternalServerError, "MarshalJSON", err) return } response(ctx, binary) @@ -166,7 +168,7 @@ func PersonOutbox(ctx *context.APIContext) { binary, err := outbox.MarshalJSON() if err != nil { - ctx.Error(http.StatusInternalServerError, "Serialize", err) + ctx.Error(http.StatusInternalServerError, "MarshalJSON", err) } response(ctx, binary) } @@ -207,7 +209,7 @@ func PersonFollowing(ctx *context.APIContext) { binary, err := following.MarshalJSON() if err != nil { - ctx.Error(http.StatusInternalServerError, "Serialize", err) + ctx.Error(http.StatusInternalServerError, "MarshalJSON", err) } response(ctx, binary) } @@ -247,7 +249,51 @@ func PersonFollowers(ctx *context.APIContext) { binary, err := followers.MarshalJSON() if err != nil { - ctx.Error(http.StatusInternalServerError, "Serialize", err) + ctx.Error(http.StatusInternalServerError, "MarshalJSON", err) + } + response(ctx, binary) +} + +// 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.Error(http.StatusInternalServerError, "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/user/" + repo.OwnerName + "/" + repo.Name)) + liked.OrderedItems.Append(repo) + } + + binary, err := liked.MarshalJSON() + if err != nil { + ctx.Error(http.StatusInternalServerError, "MarshalJSON", err) } response(ctx, binary) } diff --git a/routers/api/v1/activitypub/repo.go b/routers/api/v1/activitypub/repo.go new file mode 100644 index 000000000..8952e06db --- /dev/null +++ b/routers/api/v1/activitypub/repo.go @@ -0,0 +1,212 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package activitypub + +import ( + "io" + "net/http" + "strings" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/forgefed" + //repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/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/user/{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.Error(http.StatusInternalServerError, "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.Error(http.StatusInternalServerError, "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") + + binary, err := repo.MarshalJSON() + if err != nil { + ctx.Error(http.StatusInternalServerError, "Serialize", err) + return + } + response(ctx, binary) +} + +// RepoInbox function +func RepoInbox(ctx *context.APIContext) { + // swagger:operation POST /activitypub/user/{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.Error(http.StatusInternalServerError, "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/user/{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.Error(http.StatusInternalServerError, "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)) + + binary, err := outbox.MarshalJSON() + if err != nil { + ctx.Error(http.StatusInternalServerError, "Serialize", err) + } + response(ctx, binary) +} + +// RepoFollowers function +func RepoFollowers(ctx *context.APIContext) { + // swagger:operation GET /activitypub/user/{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.Error(http.StatusInternalServerError, "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) + } + + binary, err := followers.MarshalJSON() + if err != nil { + ctx.Error(http.StatusInternalServerError, "Serialize", err) + } + response(ctx, binary) +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index ca2837f49..2e976e966 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -651,6 +651,13 @@ func Routes() *web.Route { m.Get("/outbox", activitypub.PersonOutbox) m.Get("/following", activitypub.PersonFollowing) m.Get("/followers", activitypub.PersonFollowers) + m.Get("/liked", activitypub.PersonLiked) + m.Group("/{reponame}", func() { + m.Get("", activitypub.Repo) + m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.RepoInbox) + m.Get("/outbox", activitypub.RepoOutbox) + m.Get("/followers", activitypub.RepoFollowers) + }, repoAssignment()) }, context_service.UserAssignmentAPI()) }) } diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index beb405db1..9994df78b 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -57,8 +57,8 @@ "tags": [ "activitypub" ], - "summary": "Returns the followers collection", - "operationId": "activitypubPersonFollowers", + "summary": "Returns the Liked Collection", + "operationId": "activitypubPersonLiked", "parameters": [ { "type": "string", @@ -83,7 +83,7 @@ "tags": [ "activitypub" ], - "summary": "Returns the following collection", + "summary": "Returns the Following Collection", "operationId": "activitypubPersonFollowing", "parameters": [ { @@ -144,6 +144,79 @@ "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/user/{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/user/{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": { @@ -153,6 +226,39 @@ } } }, + "/activitypub/user/{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" + } + } + } + }, "/admin/cron": { "get": { "produces": [ |