fix: add stub outboxes to actors (#10120)

Mastodon doesn't create actors locally if the outbox is not found.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10120
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: famfo <famfo@famfo.xyz>
Co-committed-by: famfo <famfo@famfo.xyz>
This commit is contained in:
famfo 2025-12-01 16:51:35 +01:00 committed by Gusted
parent 3f207017a8
commit b428d47aaa
9 changed files with 178 additions and 37 deletions

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -99,7 +99,7 @@ func PersonFeed(ctx *context.APIContext) {
// required: true
// responses:
// "200":
// "$ref": "#/responses/PersonFeed"
// "$ref": "#/responses/Outbox"
// "403":
// "$ref": "#/responses/forbidden"

View file

@ -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)
}
}

View file

@ -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))
}

View file

@ -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"`
}

View file

@ -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": {

View file

@ -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) {

View file

@ -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)