diff --git a/models/user/user_system.go b/models/user/user_system.go index ac2505dd14..ba9a2131b2 100644 --- a/models/user/user_system.go +++ b/models/user/user_system.go @@ -4,8 +4,10 @@ package user import ( + "net/url" "strings" + "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" ) @@ -68,3 +70,28 @@ func NewActionsUser() *User { func (u *User) IsActions() bool { return u != nil && u.ID == ActionsUserID } + +const ( + APActorUserID = -3 + APActorUserName = "actor" + APActorEmail = "noreply@forgejo.org" +) + +func NewAPActorUser() *User { + return &User{ + ID: APActorUserID, + Name: APActorUserName, + LowerName: APActorUserName, + IsActive: true, + Email: APActorEmail, + KeepEmailPrivate: true, + LoginName: APActorUserName, + Type: UserTypeIndividual, + Visibility: structs.VisibleTypePublic, + } +} + +func APActorUserAPActorID() string { + path, _ := url.JoinPath(setting.AppURL, "/api/v1/activitypub/actor") + return path +} diff --git a/routers/api/v1/activitypub/actor.go b/routers/api/v1/activitypub/actor.go new file mode 100644 index 0000000000..4f128e74c4 --- /dev/null +++ b/routers/api/v1/activitypub/actor.go @@ -0,0 +1,83 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package activitypub + +import ( + "net/http" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/activitypub" + "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/services/context" + + ap "github.com/go-ap/activitypub" + "github.com/go-ap/jsonld" +) + +// Actor function returns the instance's Actor +func Actor(ctx *context.APIContext) { + // swagger:operation GET /activitypub/actor activitypub activitypubInstanceActor + // --- + // summary: Returns the instance's Actor + // produces: + // - application/json + // responses: + // "200": + // "$ref": "#/responses/ActivityPub" + + link := user_model.APActorUserAPActorID() + actor := ap.ActorNew(ap.IRI(link), ap.ApplicationType) + + actor.PreferredUsername = ap.NaturalLanguageValuesNew() + err := actor.PreferredUsername.Set("en", ap.Content(setting.Domain)) + if err != nil { + ctx.ServerError("PreferredUsername.Set", err) + return + } + + actor.URL = ap.IRI(setting.AppURL) + + actor.Inbox = ap.IRI(link + "/inbox") + actor.Outbox = ap.IRI(link + "/outbox") + + actor.PublicKey.ID = ap.IRI(link + "#main-key") + actor.PublicKey.Owner = ap.IRI(link) + + publicKeyPem, err := activitypub.GetPublicKey(ctx, user_model.NewAPActorUser()) + if err != nil { + ctx.ServerError("GetPublicKey", err) + return + } + actor.PublicKey.PublicKeyPem = publicKeyPem + + binary, err := jsonld.WithContext( + jsonld.IRI(ap.ActivityBaseURI), + jsonld.IRI(ap.SecurityContextURI), + ).Marshal(actor) + 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) + } +} + +// ActorInbox function handles the incoming data for the instance Actor +func ActorInbox(ctx *context.APIContext) { + // swagger:operation POST /activitypub/actor/inbox activitypub activitypubInstanceActorInbox + // --- + // summary: Send to the inbox + // produces: + // - application/json + // responses: + // "204": + // "$ref": "#/responses/empty" + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index fa0cd6c753..c65e738715 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -805,6 +805,10 @@ func Routes() *web.Route { m.Get("", activitypub.Person) m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox) }, context.UserIDAssignmentAPI()) + m.Group("/actor", func() { + m.Get("", activitypub.Actor) + m.Post("/inbox", activitypub.ActorInbox) + }) m.Group("/repository-id/{repository-id}", func() { m.Get("", activitypub.Repository) m.Post("/inbox", diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 39b92f4e79..628e8d5c99 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -23,6 +23,40 @@ }, "basePath": "{{AppSubUrl | JSEscape}}/api/v1", "paths": { + "/activitypub/actor": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "activitypub" + ], + "summary": "Returns the instance's Actor", + "operationId": "activitypubInstanceActor", + "responses": { + "200": { + "$ref": "#/responses/ActivityPub" + } + } + } + }, + "/activitypub/actor/inbox": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "activitypub" + ], + "summary": "Send to the inbox", + "operationId": "activitypubInstanceActorInbox", + "responses": { + "204": { + "$ref": "#/responses/empty" + } + } + } + }, "/activitypub/repository-id/{repository-id}": { "get": { "produces": [ diff --git a/tests/integration/api_activitypub_actor_test.go b/tests/integration/api_activitypub_actor_test.go new file mode 100644 index 0000000000..7506c786da --- /dev/null +++ b/tests/integration/api_activitypub_actor_test.go @@ -0,0 +1,50 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "net/http" + "net/url" + "testing" + + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/test" + "code.gitea.io/gitea/routers" + + ap "github.com/go-ap/activitypub" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestActivityPubActor(t *testing.T) { + defer test.MockVariableValue(&setting.Federation.Enabled, true)() + defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())() + + onGiteaRun(t, func(*testing.T, *url.URL) { + req := NewRequest(t, "GET", "/api/v1/activitypub/actor") + resp := MakeRequest(t, req, http.StatusOK) + body := resp.Body.Bytes() + assert.Contains(t, string(body), "@context") + + var actor ap.Actor + err := actor.UnmarshalJSON(body) + require.NoError(t, err) + + assert.Equal(t, ap.ApplicationType, actor.Type) + assert.Equal(t, setting.Domain, actor.PreferredUsername.String()) + keyID := actor.GetID().String() + assert.Regexp(t, "activitypub/actor$", keyID) + assert.Regexp(t, "activitypub/actor/outbox$", actor.Outbox.GetID().String()) + assert.Regexp(t, "activitypub/actor/inbox$", actor.Inbox.GetID().String()) + + pubKey := actor.PublicKey + assert.NotNil(t, pubKey) + publicKeyID := keyID + "#main-key" + assert.Equal(t, pubKey.ID.String(), publicKeyID) + + pubKeyPem := pubKey.PublicKeyPem + assert.NotNil(t, pubKeyPem) + assert.Regexp(t, "^-----BEGIN PUBLIC KEY-----", pubKeyPem) + }) +}