diff --git a/go.mod b/go.mod index e9b4194c7..9871d9558 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/models/auth/source.go b/models/auth/source.go index 6f4f5addc..c152644aa 100644 --- a/models/auth/source.go +++ b/models/auth/source.go @@ -22,14 +22,15 @@ type Type int // Note: new type must append to the end of list to maintain compatibility. const ( - NoType Type = iota - Plain // 1 - LDAP // 2 - SMTP // 3 - PAM // 4 - DLDAP // 5 - OAuth2 // 6 - SSPI // 7 + NoType Type = iota + Plain // 1 + LDAP // 2 + SMTP // 3 + PAM // 4 + DLDAP // 5 + OAuth2 // 6 + SSPI // 7 + Federated // 8 ) // String returns the string name of the LoginType @@ -178,6 +179,11 @@ func (source *Source) IsSSPI() bool { return source.Type == SSPI } +// IsFederated returns true of this source is of the Federated type. +func (source *Source) IsFederated() bool { + return source.Type == Federated +} + // HasTLS returns true of this source supports TLS. func (source *Source) HasTLS() bool { hasTLSer, ok := source.Cfg.(HasTLSer) diff --git a/models/db/name.go b/models/db/name.go index 9c9d18f18..ae1876a5f 100644 --- a/models/db/name.go +++ b/models/db/name.go @@ -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. diff --git a/models/forgefed/repository.go b/models/forgefed/repository.go new file mode 100644 index 000000000..8c8ae1d31 --- /dev/null +++ b/models/forgefed/repository.go @@ -0,0 +1,71 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package 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) + }) +} 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/modules/activitypub/follow.go b/modules/activitypub/follow.go new file mode 100644 index 000000000..919b697a8 --- /dev/null +++ b/modules/activitypub/follow.go @@ -0,0 +1,46 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package 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) +} diff --git a/modules/activitypub/send.go b/modules/activitypub/send.go new file mode 100644 index 000000000..96b64d205 --- /dev/null +++ b/modules/activitypub/send.go @@ -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)) + } +} diff --git a/modules/activitypub/user.go b/modules/activitypub/user.go new file mode 100644 index 000000000..b58f370ed --- /dev/null +++ b/modules/activitypub/user.go @@ -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) +} diff --git a/routers/api/v1/activitypub/person.go b/routers/api/v1/activitypub/person.go index 7290f1cbd..32d1c2a70 100644 --- a/routers/api/v1/activitypub/person.go +++ b/routers/api/v1/activitypub/person.go @@ -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) +} diff --git a/routers/api/v1/activitypub/repo.go b/routers/api/v1/activitypub/repo.go new file mode 100644 index 000000000..a766c41ef --- /dev/null +++ b/routers/api/v1/activitypub/repo.go @@ -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) +} diff --git a/routers/api/v1/activitypub/reqsignature.go b/routers/api/v1/activitypub/reqsignature.go index b870d1c0f..f7a2e9b7c 100644 --- a/routers/api/v1/activitypub/reqsignature.go +++ b/routers/api/v1/activitypub/reqsignature.go @@ -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 } diff --git a/routers/api/v1/activitypub/response.go b/routers/api/v1/activitypub/response.go new file mode 100644 index 000000000..fb1465352 --- /dev/null +++ b/routers/api/v1/activitypub/response.go @@ -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) + } +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index c93606ae8..59d5cd231 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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) diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go index dd5804cd4..bf2660881 100644 --- a/routers/web/user/profile.go +++ b/routers/web/user/profile.go @@ -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 diff --git a/routers/web/webfinger.go b/routers/web/webfinger.go index c4808fbfd..b52c1879a 100644 --- a/routers/web/webfinger.go +++ b/routers/web/webfinger.go @@ -29,6 +29,7 @@ type webfingerLink struct { Rel string `json:"rel,omitempty"` Type string `json:"type,omitempty"` Href string `json:"href,omitempty"` + Template string `json:"template,omitempty"` Titles map[string]string `json:"titles,omitempty"` Properties map[string]interface{} `json:"properties,omitempty"` } @@ -107,6 +108,10 @@ func WebfingerQuery(ctx *context.Context) { Type: "application/activity+json", Href: appURL.String() + "api/v1/activitypub/user/" + url.PathEscape(u.Name), }, + { + Rel: "http://ostatus.org/schema/1.0/subscribe", + Template: appURL.String() + "api/v1/authorize_interaction?uri={uri}", + }, } ctx.Resp.Header().Add("Access-Control-Allow-Origin", "*") diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index f3f9a3367..6ad45012e 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -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": [