mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2024-11-10 01:05:14 +00:00
Merge pull request 'Implement an instance-wide activitypub actor' (#4811) from algernon/forgejo:to-be-or-not-to-be into forgejo
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/4811 Reviewed-by: Gusted <gusted@noreply.codeberg.org> Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
This commit is contained in:
commit
6e98a57096
|
@ -4,8 +4,10 @@
|
||||||
package user
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"code.gitea.io/gitea/modules/structs"
|
"code.gitea.io/gitea/modules/structs"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -68,3 +70,28 @@ func NewActionsUser() *User {
|
||||||
func (u *User) IsActions() bool {
|
func (u *User) IsActions() bool {
|
||||||
return u != nil && u.ID == ActionsUserID
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -36,16 +36,19 @@ func CurrentTime() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func containsRequiredHTTPHeaders(method string, headers []string) error {
|
func containsRequiredHTTPHeaders(method string, headers []string) error {
|
||||||
var hasRequestTarget, hasDate, hasDigest bool
|
var hasRequestTarget, hasDate, hasDigest, hasHost bool
|
||||||
for _, header := range headers {
|
for _, header := range headers {
|
||||||
hasRequestTarget = hasRequestTarget || header == httpsig.RequestTarget
|
hasRequestTarget = hasRequestTarget || header == httpsig.RequestTarget
|
||||||
hasDate = hasDate || header == "Date"
|
hasDate = hasDate || header == "Date"
|
||||||
hasDigest = hasDigest || header == "Digest"
|
hasDigest = hasDigest || header == "Digest"
|
||||||
|
hasHost = hasHost || header == "Host"
|
||||||
}
|
}
|
||||||
if !hasRequestTarget {
|
if !hasRequestTarget {
|
||||||
return fmt.Errorf("missing http header for %s: %s", method, httpsig.RequestTarget)
|
return fmt.Errorf("missing http header for %s: %s", method, httpsig.RequestTarget)
|
||||||
} else if !hasDate {
|
} else if !hasDate {
|
||||||
return fmt.Errorf("missing http header for %s: Date", method)
|
return fmt.Errorf("missing http header for %s: Date", method)
|
||||||
|
} else if !hasHost {
|
||||||
|
return fmt.Errorf("missing http header for %s: Host", method)
|
||||||
} else if !hasDigest && method != http.MethodGet {
|
} else if !hasDigest && method != http.MethodGet {
|
||||||
return fmt.Errorf("missing http header for %s: Digest", method)
|
return fmt.Errorf("missing http header for %s: Digest", method)
|
||||||
}
|
}
|
||||||
|
@ -99,29 +102,36 @@ func NewClient(ctx context.Context, user *user_model.User, pubID string) (c *Cli
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRequest function
|
// NewRequest function
|
||||||
func (c *Client) NewRequest(method string, b []byte, to string) (req *http.Request, err error) {
|
func (c *Client) newRequest(method string, b []byte, to string) (req *http.Request, err error) {
|
||||||
buf := bytes.NewBuffer(b)
|
buf := bytes.NewBuffer(b)
|
||||||
req, err = http.NewRequest(method, to, buf)
|
req, err = http.NewRequest(method, to, buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
req.Header.Add("Content-Type", ActivityStreamsContentType)
|
req.Header.Add("Accept", "application/json, "+ActivityStreamsContentType)
|
||||||
req.Header.Add("Date", CurrentTime())
|
req.Header.Add("Date", CurrentTime())
|
||||||
|
req.Header.Add("Host", req.URL.Host)
|
||||||
req.Header.Add("User-Agent", "Gitea/"+setting.AppVer)
|
req.Header.Add("User-Agent", "Gitea/"+setting.AppVer)
|
||||||
signer, _, err := httpsig.NewSigner(c.algs, c.digestAlg, c.postHeaders, httpsig.Signature, httpsigExpirationTime)
|
req.Header.Add("Content-Type", ActivityStreamsContentType)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
err = signer.SignRequest(c.priv, c.pubID, req, b)
|
|
||||||
return req, err
|
return req, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post function
|
// Post function
|
||||||
func (c *Client) Post(b []byte, to string) (resp *http.Response, err error) {
|
func (c *Client) Post(b []byte, to string) (resp *http.Response, err error) {
|
||||||
var req *http.Request
|
var req *http.Request
|
||||||
if req, err = c.NewRequest(http.MethodPost, b, to); err != nil {
|
if req, err = c.newRequest(http.MethodPost, b, to); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signer, _, err := httpsig.NewSigner(c.algs, c.digestAlg, c.postHeaders, httpsig.Signature, httpsigExpirationTime)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := signer.SignRequest(c.priv, c.pubID, req, b); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
resp, err = c.client.Do(req)
|
resp, err = c.client.Do(req)
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
|
@ -129,10 +139,17 @@ func (c *Client) Post(b []byte, to string) (resp *http.Response, err error) {
|
||||||
// Create an http GET request with forgejo/gitea specific headers
|
// Create an http GET request with forgejo/gitea specific headers
|
||||||
func (c *Client) Get(to string) (resp *http.Response, err error) {
|
func (c *Client) Get(to string) (resp *http.Response, err error) {
|
||||||
var req *http.Request
|
var req *http.Request
|
||||||
emptyBody := []byte{0}
|
if req, err = c.newRequest(http.MethodGet, nil, to); err != nil {
|
||||||
if req, err = c.NewRequest(http.MethodGet, emptyBody, to); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
signer, _, err := httpsig.NewSigner(c.algs, c.digestAlg, c.getHeaders, httpsig.Signature, httpsigExpirationTime)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := signer.SignRequest(c.priv, c.pubID, req, nil); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
resp, err = c.client.Do(req)
|
resp, err = c.client.Do(req)
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,8 +25,8 @@ var (
|
||||||
MaxSize: 4,
|
MaxSize: 4,
|
||||||
Algorithms: []string{"rsa-sha256", "rsa-sha512", "ed25519"},
|
Algorithms: []string{"rsa-sha256", "rsa-sha512", "ed25519"},
|
||||||
DigestAlgorithm: "SHA-256",
|
DigestAlgorithm: "SHA-256",
|
||||||
GetHeaders: []string{"(request-target)", "Date"},
|
GetHeaders: []string{"(request-target)", "Date", "Host"},
|
||||||
PostHeaders: []string{"(request-target)", "Date", "Digest"},
|
PostHeaders: []string{"(request-target)", "Date", "Host", "Digest"},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
83
routers/api/v1/activitypub/actor.go
Normal file
83
routers/api/v1/activitypub/actor.go
Normal file
|
@ -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)
|
||||||
|
}
|
|
@ -805,6 +805,10 @@ func Routes() *web.Route {
|
||||||
m.Get("", activitypub.Person)
|
m.Get("", activitypub.Person)
|
||||||
m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox)
|
m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.PersonInbox)
|
||||||
}, context.UserIDAssignmentAPI())
|
}, context.UserIDAssignmentAPI())
|
||||||
|
m.Group("/actor", func() {
|
||||||
|
m.Get("", activitypub.Actor)
|
||||||
|
m.Post("/inbox", activitypub.ActorInbox)
|
||||||
|
})
|
||||||
m.Group("/repository-id/{repository-id}", func() {
|
m.Group("/repository-id/{repository-id}", func() {
|
||||||
m.Get("", activitypub.Repository)
|
m.Get("", activitypub.Repository)
|
||||||
m.Post("/inbox",
|
m.Post("/inbox",
|
||||||
|
|
34
templates/swagger/v1_json.tmpl
generated
34
templates/swagger/v1_json.tmpl
generated
|
@ -23,6 +23,40 @@
|
||||||
},
|
},
|
||||||
"basePath": "{{AppSubUrl | JSEscape}}/api/v1",
|
"basePath": "{{AppSubUrl | JSEscape}}/api/v1",
|
||||||
"paths": {
|
"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}": {
|
"/activitypub/repository-id/{repository-id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"produces": [
|
"produces": [
|
||||||
|
|
50
tests/integration/api_activitypub_actor_test.go
Normal file
50
tests/integration/api_activitypub_actor_test.go
Normal file
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
Loading…
Reference in a new issue