diff --git a/modules/forgefed/outbox.go b/modules/forgefed/outbox.go new file mode 100644 index 0000000000..245d6d4edc --- /dev/null +++ b/modules/forgefed/outbox.go @@ -0,0 +1,15 @@ +// Copyright 2024, 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package forgefed + +import ( + ap "github.com/go-ap/activitypub" +) + +// ActivityStream OrderedCollection of activities +// swagger:model +type ForgeOutbox struct { + // swagger:ignore + ap.OutboxStream +} diff --git a/routers/api/v1/activitypub/actor.go b/routers/api/v1/activitypub/actor.go index 0ff822c7f4..f8b08e92c3 100644 --- a/routers/api/v1/activitypub/actor.go +++ b/routers/api/v1/activitypub/actor.go @@ -41,6 +41,7 @@ func Actor(ctx *context.APIContext) { 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) @@ -79,3 +80,33 @@ func ActorInbox(ctx *context.APIContext) { ctx.Status(http.StatusNoContent) } + +func ActorOutbox(ctx *context.APIContext) { + // swagger:operation POST /activitypub/actor/outbox activitypub activitypubInstanceActorOutbox + // --- + // summary: Display the outbox (always empty) + // produces: + // - application/ld+json + // responses: + // "200": + // "$ref": "#/responses/Outbox" + + link := user_model.APServerActorID() + outbox := ap.OrderedCollectionNew(ap.IRI(link + "/outbox")) + + binary, err := jsonld.WithContext( + jsonld.IRI(ap.ActivityBaseURI), + ).Marshal(outbox) + if err != nil { + ctx.ServerError("MarshalJSON", err) + return + } + + ctx.Resp.Header().Add("Content-Type", activitypub.ActivityStreamsContentType) + ctx.Resp.WriteHeader(http.StatusOK) + + _, err = ctx.Resp.Write(binary) + if err != nil { + log.Error("write to resp err: %s", err) + } +} diff --git a/routers/api/v1/activitypub/person.go b/routers/api/v1/activitypub/person.go index 7ac1ed3d3f..8fa9e5f226 100644 --- a/routers/api/v1/activitypub/person.go +++ b/routers/api/v1/activitypub/person.go @@ -99,7 +99,7 @@ func PersonFeed(ctx *context.APIContext) { // required: true // responses: // "200": - // "$ref": "#/responses/PersonFeed" + // "$ref": "#/responses/Outbox" // "403": // "$ref": "#/responses/forbidden" diff --git a/routers/api/v1/activitypub/repository.go b/routers/api/v1/activitypub/repository.go index b254c4b073..178f4b246f 100644 --- a/routers/api/v1/activitypub/repository.go +++ b/routers/api/v1/activitypub/repository.go @@ -8,6 +8,7 @@ import ( "net/http" "strings" + "forgejo.org/modules/activitypub" "forgejo.org/modules/forgefed" "forgejo.org/modules/log" "forgejo.org/modules/setting" @@ -16,6 +17,7 @@ import ( "forgejo.org/services/federation" ap "github.com/go-ap/activitypub" + "github.com/go-ap/jsonld" ) // Repository function returns the Repository actor for a repo @@ -39,6 +41,9 @@ func Repository(ctx *context.APIContext) { link := fmt.Sprintf("%s/api/v1/activitypub/repository-id/%d", strings.TrimSuffix(setting.AppURL, "/"), ctx.Repo.Repository.ID) repo := forgefed.RepositoryNew(ap.IRI(link)) + repo.Inbox = ap.IRI(link + "/inbox") + repo.Outbox = ap.IRI(link + "/outbox") + repo.Name = ap.NaturalLanguageValuesNew() err := repo.Name.Set("en", ap.Content(ctx.Repo.Repository.Name)) if err != nil { @@ -81,3 +86,40 @@ func RepositoryInbox(ctx *context.APIContext) { } responseServiceResult(ctx, result) } + +func RepositoryOutbox(ctx *context.APIContext) { + // swagger:operation POST /activitypub/repository-id/{repository-id}/outbox activitypub activitypubRepositoryOutbox + // --- + // summary: Display the outbox + // produces: + // - application/ld+json + // parameters: + // - name: repository-id + // in: path + // description: repository ID of the repo + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/Outbox" + + repository := ctx.Repo.Repository + outbox := ap.OrderedCollectionNew(ap.IRI(repository.APActorID() + "/outbox")) + + binary, err := jsonld.WithContext( + jsonld.IRI(ap.ActivityBaseURI), + ).Marshal(outbox) + if err != nil { + ctx.ServerError("MarshalJSON", err) + return + } + + ctx.Resp.Header().Add("Content-Type", activitypub.ActivityStreamsContentType) + ctx.Resp.WriteHeader(http.StatusOK) + + _, err = ctx.Resp.Write(binary) + if err != nil { + log.Error("write to resp err: %s", err) + } +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 62b91009e0..322469d240 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -892,6 +892,7 @@ func Routes() *web.Route { m.Group("/actor", func() { m.Get("", activitypub.Actor) m.Post("/inbox", activitypub.ReqHTTPUserOrInstanceSignature(), activitypub.ActorInbox) + m.Get("/outbox", activitypub.ActorOutbox) }) m.Group("/repository-id/{repository-id}", func() { m.Get("", activitypub.ReqHTTPUserSignature(), activitypub.Repository) @@ -899,6 +900,7 @@ func Routes() *web.Route { bind(ap.Activity{}), activitypub.ReqHTTPUserSignature(), activitypub.RepositoryInbox) + m.Get("/outbox", activitypub.ReqHTTPUserSignature(), activitypub.RepositoryOutbox) }, context.RepositoryIDAssignmentAPI()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryActivityPub)) } diff --git a/routers/api/v1/swagger/activitypub.go b/routers/api/v1/swagger/activitypub.go index a11fc4098c..548d5669e8 100644 --- a/routers/api/v1/swagger/activitypub.go +++ b/routers/api/v1/swagger/activitypub.go @@ -4,6 +4,7 @@ package swagger import ( + "forgejo.org/modules/forgefed" api "forgejo.org/modules/structs" ) @@ -14,9 +15,9 @@ type swaggerResponseActivityPub struct { Body api.ActivityPub `json:"body"` } -// Personfeed -// swagger:response PersonFeed -type swaggerResponsePersonFeed struct { +// Outbox +// swagger:response Outbox +type swaggerResponseOutbox struct { // in:body - Body []api.APPersonFollowItem `json:"body"` + Body forgefed.ForgeOutbox `json:"body"` } diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index aa337e02af..bb4d86c7c2 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -57,6 +57,23 @@ } } }, + "/activitypub/actor/outbox": { + "post": { + "produces": [ + "application/ld+json" + ], + "tags": [ + "activitypub" + ], + "summary": "Display the outbox (always empty)", + "operationId": "activitypubInstanceActorOutbox", + "responses": { + "200": { + "$ref": "#/responses/Outbox" + } + } + } + }, "/activitypub/repository-id/{repository-id}": { "get": { "produces": [ @@ -118,6 +135,33 @@ } } }, + "/activitypub/repository-id/{repository-id}/outbox": { + "post": { + "produces": [ + "application/ld+json" + ], + "tags": [ + "activitypub" + ], + "summary": "Display the outbox", + "operationId": "activitypubRepositoryOutbox", + "parameters": [ + { + "type": "integer", + "format": "int64", + "description": "repository ID of the repo", + "name": "repository-id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/Outbox" + } + } + } + }, "/activitypub/user-id/{user-id}": { "get": { "produces": [ @@ -259,7 +303,7 @@ ], "responses": { "200": { - "$ref": "#/responses/PersonFeed" + "$ref": "#/responses/Outbox" }, "403": { "$ref": "#/responses/forbidden" @@ -21925,28 +21969,6 @@ }, "x-go-package": "forgejo.org/services/context" }, - "APPersonFollowItem": { - "type": "object", - "properties": { - "actor_id": { - "type": "string", - "x-go-name": "ActorID" - }, - "note": { - "type": "string", - "x-go-name": "Note" - }, - "original_item": { - "type": "string", - "x-go-name": "OriginalItem" - }, - "original_url": { - "type": "string", - "x-go-name": "OriginalURL" - } - }, - "x-go-package": "forgejo.org/modules/structs" - }, "AccessToken": { "type": "object", "title": "AccessToken represents an API access token.", @@ -25707,6 +25729,11 @@ "type": "object", "x-go-package": "forgejo.org/modules/forgefed" }, + "ForgeOutbox": { + "description": "ActivityStream OrderedCollection of activities", + "type": "object", + "x-go-package": "forgejo.org/modules/forgefed" + }, "GPGKey": { "description": "GPGKey a user GPG key to sign commit and tag in repository", "type": "object", @@ -30489,6 +30516,12 @@ "$ref": "#/definitions/OrganizationPermissions" } }, + "Outbox": { + "description": "Outbox", + "schema": { + "$ref": "#/definitions/ForgeOutbox" + } + }, "Package": { "description": "Package", "schema": { @@ -30513,15 +30546,6 @@ } } }, - "PersonFeed": { - "description": "Personfeed", - "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/APPersonFollowItem" - } - } - }, "PublicKey": { "description": "PublicKey", "schema": { diff --git a/tests/integration/api_activitypub_actor_test.go b/tests/integration/api_activitypub_actor_test.go index 90a759716a..30e8934e1b 100644 --- a/tests/integration/api_activitypub_actor_test.go +++ b/tests/integration/api_activitypub_actor_test.go @@ -5,6 +5,7 @@ package integration import ( "fmt" + "io" "net/http" "net/url" "strconv" @@ -21,6 +22,7 @@ import ( ap "github.com/go-ap/activitypub" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/valyala/fastjson" ) func TestActivityPubActor(t *testing.T) { @@ -41,6 +43,7 @@ func TestActivityPubActor(t *testing.T) { keyID := actor.GetID().String() assert.Regexp(t, "activitypub/actor$", keyID) assert.Regexp(t, "activitypub/actor/inbox$", actor.Inbox.GetID().String()) + assert.Regexp(t, "activitypub/actor/outbox$", actor.Outbox.GetID().String()) pubKey := actor.PublicKey assert.NotNil(t, pubKey) @@ -50,6 +53,28 @@ func TestActivityPubActor(t *testing.T) { pubKeyPem := pubKey.PublicKeyPem assert.NotNil(t, pubKeyPem) assert.Regexp(t, "^-----BEGIN PUBLIC KEY-----", pubKeyPem) + + t.Run("ActorOutboxEmpty", func(t *testing.T) { + req := NewRequest(t, "GET", actor.Outbox.GetID().String()) + resp := MakeRequest(t, req, http.StatusOK) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + jsonResp, err := fastjson.ParseBytes(body) + require.NoError(t, err) + + outbox := ap.JSONUnmarshalToItem(jsonResp) + require.NoError(t, err) + + assert.Equal(t, ap.OrderedCollectionType, outbox.GetType()) + outboxCollection, ok := outbox.(*ap.OrderedCollection) + require.True(t, ok) + + assert.Equal(t, uint(0), outboxCollection.TotalItems) + assert.Nil(t, outboxCollection.First) + assert.Nil(t, outboxCollection.Last) + }) } func TestActorNewFromKeyId(t *testing.T) { diff --git a/tests/integration/api_activitypub_person_test.go b/tests/integration/api_activitypub_person_test.go index cbd1f99b23..c0f297a7d3 100644 --- a/tests/integration/api_activitypub_person_test.go +++ b/tests/integration/api_activitypub_person_test.go @@ -66,6 +66,7 @@ func TestActivityPubPerson(t *testing.T) { assert.Equal(t, localUserName, person.PreferredUsername.String()) assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%d$", localUserID), person.GetID()) assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%d/inbox$", localUserID), person.Inbox.GetID().String()) + assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%d/outbox$", localUserID), person.Outbox.GetID().String()) assert.NotNil(t, person.PublicKey) assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%d#main-key$", localUserID), person.PublicKey.ID)