From f121e87aa6c00be18025a702aa7112ed15d5d0ab Mon Sep 17 00:00:00 2001 From: Gergely Nagy Date: Mon, 5 Aug 2024 10:50:26 +0200 Subject: [PATCH] activitypub: Implement an instance-wide actor An instance-wide actor is required for outgoing signed requests that are done on behalf of the instance, rather than on behalf of other actors. Such things include updating profile information, or fetching public keys. Signed-off-by: Gergely Nagy --- models/user/user_system.go | 27 ++++++ routers/api/v1/activitypub/actor.go | 83 +++++++++++++++++++ routers/api/v1/api.go | 4 + templates/swagger/v1_json.tmpl | 34 ++++++++ .../integration/api_activitypub_actor_test.go | 50 +++++++++++ 5 files changed, 198 insertions(+) create mode 100644 routers/api/v1/activitypub/actor.go create mode 100644 tests/integration/api_activitypub_actor_test.go 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) + }) +}