mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-05-12 22:10:25 +00:00
feat: Follow remote users; feed tab (#10380)
This is hopefully the final part of PR #4767, rebased and squashed. More thorough federation tests are at https://code.forgejo.org/forgejo/end-to-end/pulls/1276 but the mock has been extended to hopefully cover a good chunk as well. Co-authored-by: Gergely Nagy <forgejo@gergo.csillger.hu> Co-authored-by: Michael Jerger <michael.jerger@meissa-gmbh.de> Co-authored-by: zam <mirco.zachmann@meissa.de> Co-authored-by: Panagiotis "Ivory" Vasilopoulos <git@n0toose.net> Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10380 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:
parent
9de142eb7f
commit
fd28fd896b
26 changed files with 599 additions and 84 deletions
|
|
@ -88,7 +88,6 @@ forgejo.org/modules/eventsource
|
|||
Event.String
|
||||
|
||||
forgejo.org/modules/forgefed
|
||||
NewForgeFollow
|
||||
NewForgeUndoLike
|
||||
ForgeUndoLike.UnmarshalJSON
|
||||
ForgeUndoLike.Validate
|
||||
|
|
@ -227,9 +226,6 @@ forgejo.org/routers/web/org
|
|||
forgejo.org/services/context
|
||||
GetPrivateContext
|
||||
|
||||
forgejo.org/services/federation
|
||||
FollowRemoteActor
|
||||
|
||||
forgejo.org/services/notify
|
||||
UnregisterNotifier
|
||||
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ func GetFollowingFeeds(ctx context.Context, actorID int64, opts GetFollowingFeed
|
|||
sess = db.SetSessionPagination(sess, &opts)
|
||||
|
||||
actions := make([]*FederatedUserActivity, 0, opts.PageSize)
|
||||
count, err := sess.FindAndCount(&actions)
|
||||
count, err := sess.Desc("`federated_user_activity`.created").FindAndCount(&actions)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("FindAndCount: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ package user
|
|||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"forgejo.org/modules/validation"
|
||||
)
|
||||
|
|
@ -42,3 +43,18 @@ func (federatedUser FederatedUser) Validate() []string {
|
|||
result = append(result, validation.ValidateNotEmpty(federatedUser.InboxPath, "InboxPath")...)
|
||||
return result
|
||||
}
|
||||
|
||||
func (federatedUser *FederatedUser) LogString() string {
|
||||
if federatedUser == nil {
|
||||
return "<FederatedUser nil>"
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"<FederatedUser ID: %d, UserID: %d, ExternalID: %s, NormalizedOriginalURL: %s, InboxPath: %s>",
|
||||
federatedUser.ID,
|
||||
federatedUser.UserID,
|
||||
federatedUser.ExternalID,
|
||||
federatedUser.NormalizedOriginalURL,
|
||||
federatedUser.InboxPath,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
15
modules/forgefed/inbox.go
Normal file
15
modules/forgefed/inbox.go
Normal 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"
|
||||
)
|
||||
|
||||
// ForgeFollow activity data type
|
||||
// swagger:model
|
||||
type ForgeInbox struct {
|
||||
// swagger:ignore
|
||||
ap.InboxStream
|
||||
}
|
||||
|
|
@ -60,6 +60,7 @@ func NewFuncMap() template.FuncMap {
|
|||
"QueryEscape": QueryEscape,
|
||||
"JSEscape": JSEscapeSafe,
|
||||
"SanitizeHTML": SanitizeHTML,
|
||||
"SanitizeHTMLStrict": SanitizeHTMLStrict,
|
||||
"URLJoin": util.URLJoin,
|
||||
"DotEscape": DotEscape,
|
||||
|
||||
|
|
@ -257,6 +258,10 @@ func SanitizeHTML(s string) template.HTML {
|
|||
return template.HTML(markup.Sanitize(s))
|
||||
}
|
||||
|
||||
func SanitizeHTMLStrict(s string) template.HTML {
|
||||
return template.HTML(markup.SanitizeDescription(s))
|
||||
}
|
||||
|
||||
func HTMLEscape(s any) template.HTML {
|
||||
switch v := s.(type) {
|
||||
case string:
|
||||
|
|
|
|||
|
|
@ -4,29 +4,38 @@
|
|||
package test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"forgejo.org/modules/util"
|
||||
|
||||
ap "github.com/go-ap/activitypub"
|
||||
"github.com/go-ap/jsonld"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ApActorMock struct {
|
||||
PrivKey string
|
||||
PubKey string
|
||||
}
|
||||
|
||||
type FederationServerMockPerson struct {
|
||||
ID int64
|
||||
Name string
|
||||
PubKey string
|
||||
PrivKey string
|
||||
}
|
||||
|
||||
type FederationServerMockRepository struct {
|
||||
ID int64
|
||||
}
|
||||
type ApActorMock struct {
|
||||
PrivKey string
|
||||
PubKey string
|
||||
}
|
||||
|
||||
type FederationServerMock struct {
|
||||
ApActor ApActorMock
|
||||
Persons []FederationServerMockPerson
|
||||
|
|
@ -34,6 +43,35 @@ type FederationServerMock struct {
|
|||
LastPost string
|
||||
}
|
||||
|
||||
func NewApActorMock() ApActorMock {
|
||||
priv, pub, _ := util.GenerateKeyPair(1024)
|
||||
return ApActorMock{
|
||||
PrivKey: priv,
|
||||
PubKey: pub,
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ApActorMock) KeyID(host string) string {
|
||||
return fmt.Sprintf("%s/api/v1/activitypub/actor#main-key", host)
|
||||
}
|
||||
|
||||
func (u *ApActorMock) marshal(host string) string {
|
||||
baseID := fmt.Sprintf("http://%s/api/v1/activitypub/actor", host)
|
||||
|
||||
return fmt.Sprintf(
|
||||
`{ "@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"],`+
|
||||
`"id": "%[1]s",`+
|
||||
`"type": "Application",`+
|
||||
`"preferredUsername": "ghost",`+
|
||||
`"publicKey": {`+
|
||||
` "id": "%[1]s#main-key",`+
|
||||
` "owner": "%[1]s",`+
|
||||
` "publicKeyPem": %[2]q }}`,
|
||||
baseID,
|
||||
u.PubKey,
|
||||
)
|
||||
}
|
||||
|
||||
func NewFederationServerMockPerson(id int64, name string) FederationServerMockPerson {
|
||||
priv, pub, _ := util.GenerateKeyPair(3072)
|
||||
return FederationServerMockPerson{
|
||||
|
|
@ -48,24 +86,6 @@ func (p *FederationServerMockPerson) KeyID(host string) string {
|
|||
return fmt.Sprintf("%[1]v/api/v1/activitypub/user-id/%[2]v#main-key", host, p.ID)
|
||||
}
|
||||
|
||||
func NewFederationServerMockRepository(id int64) FederationServerMockRepository {
|
||||
return FederationServerMockRepository{
|
||||
ID: id,
|
||||
}
|
||||
}
|
||||
|
||||
func NewApActorMock() ApActorMock {
|
||||
priv, pub, _ := util.GenerateKeyPair(1024)
|
||||
return ApActorMock{
|
||||
PrivKey: priv,
|
||||
PubKey: pub,
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ApActorMock) KeyID(host string) string {
|
||||
return fmt.Sprintf("%[1]v/api/v1/activitypub/actor#main-key", host)
|
||||
}
|
||||
|
||||
func (p FederationServerMockPerson) marshal(host string) string {
|
||||
return fmt.Sprintf(`{"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/v1"],`+
|
||||
`"id":"http://%[1]v/api/v1/activitypub/user-id/%[2]v",`+
|
||||
|
|
@ -80,6 +100,12 @@ func (p FederationServerMockPerson) marshal(host string) string {
|
|||
`"publicKeyPem":%[4]q}}`, host, p.ID, p.Name, p.PubKey)
|
||||
}
|
||||
|
||||
func NewFederationServerMockRepository(id int64) FederationServerMockRepository {
|
||||
return FederationServerMockRepository{
|
||||
ID: id,
|
||||
}
|
||||
}
|
||||
|
||||
func NewFederationServerMock() *FederationServerMock {
|
||||
return &FederationServerMock{
|
||||
ApActor: NewApActorMock(),
|
||||
|
|
@ -103,6 +129,26 @@ func (mock *FederationServerMock) recordLastPost(t *testing.T, req *http.Request
|
|||
mock.LastPost = strings.ReplaceAll(buf.String(), req.Host, "DISTANT_FEDERATION_HOST")
|
||||
}
|
||||
|
||||
func (mock *FederationServerMock) FollowActorUnsigned(host string, localID int64, uri, inboxURL url.URL) error {
|
||||
apID := fmt.Sprintf("%s/api/v1/activitypub/user-id/%d", host, localID)
|
||||
|
||||
activity := ap.Follow{}
|
||||
activity.Type = ap.FollowType
|
||||
activity.ID = ap.IRI(apID + "/follows/" + uuid.New().String())
|
||||
activity.Actor = ap.IRI(apID)
|
||||
activity.Object = ap.IRI(uri.String())
|
||||
|
||||
payload, err := jsonld.WithContext(jsonld.IRI(ap.ActivityBaseURI)).Marshal(activity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reader := bytes.NewReader(payload)
|
||||
_, err = http.Post(inboxURL.String(), "application/activity+json", reader)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (mock *FederationServerMock) DistantServer(t *testing.T) *httptest.Server {
|
||||
federatedRoutes := http.NewServeMux()
|
||||
|
||||
|
|
@ -122,6 +168,10 @@ func (mock *FederationServerMock) DistantServer(t *testing.T) *httptest.Server {
|
|||
})
|
||||
|
||||
for _, person := range mock.Persons {
|
||||
federatedRoutes.HandleFunc(fmt.Sprintf("/api/v1/activitypub/user-id/alias%v", person.ID),
|
||||
func(res http.ResponseWriter, req *http.Request) {
|
||||
fmt.Fprint(res, person.marshal(req.Host))
|
||||
})
|
||||
federatedRoutes.HandleFunc(fmt.Sprintf("/api/v1/activitypub/user-id/%v", person.ID),
|
||||
func(res http.ResponseWriter, req *http.Request) {
|
||||
// curl -H "Accept: application/json" https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/2
|
||||
|
|
@ -139,10 +189,18 @@ func (mock *FederationServerMock) DistantServer(t *testing.T) *httptest.Server {
|
|||
mock.recordLastPost(t, req)
|
||||
})
|
||||
}
|
||||
|
||||
federatedRoutes.HandleFunc("GET /api/v1/activitypub/actor",
|
||||
func(res http.ResponseWriter, req *http.Request) {
|
||||
fmt.Fprint(res, mock.ApActor.marshal(req.Host))
|
||||
})
|
||||
|
||||
federatedRoutes.HandleFunc("/",
|
||||
func(res http.ResponseWriter, req *http.Request) {
|
||||
t.Errorf("Unhandled %v request: %q", req.Method, req.URL.EscapedPath())
|
||||
})
|
||||
|
||||
federatedSrv := httptest.NewServer(federatedRoutes)
|
||||
|
||||
return federatedSrv
|
||||
}
|
||||
|
|
|
|||
|
|
@ -261,6 +261,12 @@
|
|||
"settings.access_token.admin_disabled": "Administrative permissions are disabled.",
|
||||
"error.must_enable_2fa": "This Forgejo instance requires users to enable two-factor authentication before they can access their accounts. Enable it at: %s",
|
||||
"avatar.constraints_hint": "Custom avatar may not exceed %[1]s in size or be larger than %[2]dx%[3]d pixels",
|
||||
"user.activitypub_feed.feed": "Fediverse Feed",
|
||||
"user.activitypub_feed.no_activity": "No fediverse activity",
|
||||
"user.activitypub_feed.is_empty": "Your fediverse feed is empty.",
|
||||
"user.activitypub_feed.hint": "This feed shows activities from fediverse accounts that you follow, as well as federated posts that mentioned you.",
|
||||
"user.activitypub_feed.posted_on": "Posted on %[1]s",
|
||||
"user.activitypub_feed.original_source": "Original source",
|
||||
"user.ghost.tooltip": "This user has been deleted, or cannot be matched.",
|
||||
"og.repo.summary_card.alt_description": "Summary card of repository %[1]s, described as: %[2]s",
|
||||
"repo.commit.load_tags_failed": "Load tags failed because of internal error",
|
||||
|
|
|
|||
1
public/assets/img/svg/fediverse-small.svg
generated
Normal file
1
public/assets/img/svg/fediverse-small.svg
generated
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 6.35 6.35" class="svg fediverse-small" width="16" height="16" aria-hidden="true"><path d="M3.59 1.16a.204.204 0 0 0-.24.16.204.204 0 0 0 .158.24 1.63 1.63 0 0 1 .832.45 1.64 1.64 0 0 1 .353 1.806.204.204 0 0 0 .11.268.204.204 0 0 0 .265-.11A2.055 2.055 0 0 0 3.59 1.16M1.547 2.266a.204.204 0 0 0-.266.109 2.054 2.054 0 0 0 1.48 2.814.204.204 0 0 0 .241-.158.204.204 0 0 0-.16-.242 1.63 1.63 0 0 1-.832-.45 1.64 1.64 0 0 1-.483-1.163c0-.228.046-.446.13-.643a.204.204 0 0 0-.11-.267" style="stroke-linecap:round" transform="matrix(1.28704 0 0 1.31102 -.911 -.987)"/><path d="M1.72.264C1.065.264.53.802.53 1.456c0 .655.535 1.19 1.19 1.19s1.19-.535 1.19-1.19S2.373.264 1.72.264m0 .53c.368 0 .661.294.661.662a.66.66 0 0 1-.661.662.66.66 0 0 1-.662-.662c0-.368.293-.661.662-.661M4.63 3.705c-.654 0-1.19.535-1.19 1.19s.536 1.19 1.19 1.19c.655 0 1.19-.536 1.19-1.19 0-.655-.535-1.19-1.19-1.19m0 .527c.37 0 .661.294.661.663a.657.657 0 0 1-.66.662.66.66 0 0 1-.662-.662c0-.369.293-.663.662-.663"/></svg>
|
||||
|
After Width: | Height: | Size: 1 KiB |
1
release-notes/10380.md
Normal file
1
release-notes/10380.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
Allow forgejo users to follow remote users and display federated notes. This feature is still missing some some other basic features for "production use": there is no moderation support, UI for following federated users, and federation is still limited to forgejo, Mastodon, and GoToSocial.
|
||||
|
|
@ -1036,6 +1036,11 @@ func Routes() *web.Route {
|
|||
m.Delete("", user.Unfollow)
|
||||
}, context.UserAssignmentAPI())
|
||||
})
|
||||
if setting.Federation.Enabled {
|
||||
m.Group("/activitypub", func() {
|
||||
m.Post("/follow", bind(api.APRemoteFollowOption{}), user.ActivityPubFollow)
|
||||
})
|
||||
}
|
||||
|
||||
// (admin:public_key scope)
|
||||
m.Group("/keys", func() {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ type swaggerParameterBodies struct {
|
|||
// in:body
|
||||
ForgeLike ffed.ForgeLike
|
||||
|
||||
// in:body
|
||||
APRemoteFollowOption api.APRemoteFollowOption `json:"body"`
|
||||
|
||||
// in:body
|
||||
AddCollaboratorOption api.AddCollaboratorOption
|
||||
|
||||
|
|
|
|||
|
|
@ -10,9 +10,11 @@ import (
|
|||
|
||||
user_model "forgejo.org/models/user"
|
||||
api "forgejo.org/modules/structs"
|
||||
"forgejo.org/modules/web"
|
||||
"forgejo.org/routers/api/v1/utils"
|
||||
"forgejo.org/services/context"
|
||||
"forgejo.org/services/convert"
|
||||
"forgejo.org/services/federation"
|
||||
)
|
||||
|
||||
func responseAPIUsers(ctx *context.APIContext, users []*user_model.User) {
|
||||
|
|
@ -279,3 +281,33 @@ func Unfollow(ctx *context.APIContext) {
|
|||
}
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Follow follow a remote activitypub account
|
||||
func ActivityPubFollow(ctx *context.APIContext) {
|
||||
// swagger:operation POST /user/activitypub/follow user userCurrentActivityPubFollow
|
||||
// ---
|
||||
// summary: Follow a remote activitypub account
|
||||
// parameters:
|
||||
// - name: body
|
||||
// in: body
|
||||
// schema:
|
||||
// "$ref": "#/definitions/APRemoteFollowOption"
|
||||
// responses:
|
||||
// "204":
|
||||
// "$ref": "#/responses/empty"
|
||||
// "401":
|
||||
// "$ref": "#/responses/unauthorized"
|
||||
// "404":
|
||||
// "$ref": "#/responses/notFound"
|
||||
// "403":
|
||||
// "$ref": "#/responses/forbidden"
|
||||
|
||||
form := web.GetForm(ctx).(*api.APRemoteFollowOption)
|
||||
|
||||
if err := federation.FollowRemoteActor(ctx, ctx.Doer, form.Target); err != nil {
|
||||
ctx.Error(http.StatusInternalServerError, "federation.FollowRemoteActor", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -176,6 +176,28 @@ func prepareUserProfileTabData(ctx *context.Context, showPrivate bool, profileDb
|
|||
} else {
|
||||
ctx.Data["CardsNoneMsg"] = ctx.Tr("followers.outgoing.list.none", ctx.ContextUser.Name)
|
||||
}
|
||||
case "feed":
|
||||
if setting.Federation.Enabled {
|
||||
pagingNum = setting.UI.FeedPagingNum
|
||||
var items []*activities_model.FederatedUserActivity
|
||||
var count int64
|
||||
if ctx.Doer != nil {
|
||||
items, count, err = activities_model.GetFollowingFeeds(ctx,
|
||||
ctx.Doer.ID,
|
||||
activities_model.GetFollowingFeedsOptions{
|
||||
ListOptions: db.ListOptions{
|
||||
PageSize: pagingNum,
|
||||
Page: page,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
ctx.ServerError("GetFollowingFeeds", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
ctx.Data["FollowingFeeds"] = items
|
||||
total = int(count)
|
||||
}
|
||||
case "activity":
|
||||
// prepare heatmap data
|
||||
if setting.Service.EnableUserHeatmap {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"forgejo.org/models/forgefed"
|
||||
"forgejo.org/models/user"
|
||||
user_model "forgejo.org/models/user"
|
||||
"forgejo.org/modules/activitypub"
|
||||
fm "forgejo.org/modules/forgefed"
|
||||
"forgejo.org/modules/log"
|
||||
|
|
@ -47,32 +47,51 @@ func FindOrCreateFederationHost(ctx context.Context, actorURI string) (*forgefed
|
|||
return federationHost, nil
|
||||
}
|
||||
|
||||
func FindOrCreateFederatedUser(ctx context.Context, actorURI string) (*user.User, *user.FederatedUser, *forgefed.FederationHost, error) {
|
||||
func FindOrCreateFederatedUser(ctx context.Context, actorURI string) (*user_model.User, *user_model.FederatedUser, *forgefed.FederationHost, error) {
|
||||
user, federatedUser, federationHost, err := findFederatedUser(ctx, actorURI)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
personID, err := fm.NewPersonID(actorURI, string(federationHost.NodeInfo.SoftwareName))
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
if user != nil {
|
||||
log.Trace("Local ActivityPub user found (actorURI: %#v, user: %#v)", actorURI, user)
|
||||
log.Trace("Local ActivityPub user found (actorURI: %#v, user: %v)", actorURI, user.Name)
|
||||
} else {
|
||||
log.Trace("Attempting to create new user and federatedUser for actorURI: %#v", actorURI)
|
||||
user, federatedUser, err = createUserFromAP(ctx, personID, federationHost.ID)
|
||||
apUser, apFederatedUser, err := fetchUserFromAP(ctx, personID, federationHost)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
log.Trace("Created user %#v with federatedUser %#v from distant server", user, federatedUser)
|
||||
|
||||
user, federatedUser, federationHost, err = findFederatedUser(ctx, apFederatedUser.NormalizedOriginalURL)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
if user != nil {
|
||||
log.Trace("Resolved alias %s to %s", actorURI, apFederatedUser.NormalizedOriginalURL)
|
||||
} else {
|
||||
user = apUser
|
||||
federatedUser = apFederatedUser
|
||||
|
||||
err := user_model.CreateFederatedUser(ctx, user, federatedUser)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
log.Trace("Created user %s with federatedUser %s from distant server", user.LogString(), federatedUser.LogString())
|
||||
}
|
||||
}
|
||||
log.Trace("Got user: %v", user.Name)
|
||||
|
||||
return user, federatedUser, federationHost, nil
|
||||
}
|
||||
|
||||
func findFederatedUser(ctx context.Context, actorURI string) (*user.User, *user.FederatedUser, *forgefed.FederationHost, error) {
|
||||
func findFederatedUser(ctx context.Context, actorURI string) (*user_model.User, *user_model.FederatedUser, *forgefed.FederationHost, error) {
|
||||
federationHost, err := FindOrCreateFederationHost(ctx, actorURI)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
|
|
@ -82,7 +101,7 @@ func findFederatedUser(ctx context.Context, actorURI string) (*user.User, *user.
|
|||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
user, federatedUser, err := user.FindFederatedUser(ctx, actorID.ID, federationHost.ID)
|
||||
user, federatedUser, err := user_model.FindFederatedUser(ctx, actorID.ID, federationHost.ID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
|
@ -91,7 +110,7 @@ func findFederatedUser(ctx context.Context, actorURI string) (*user.User, *user.
|
|||
}
|
||||
|
||||
func createFederationHostFromAP(ctx context.Context, actorID fm.ActorID) (*forgefed.FederationHost, error) {
|
||||
actionsUser := user.NewAPServerActor()
|
||||
actionsUser := user_model.NewAPServerActor()
|
||||
|
||||
clientFactory, err := activitypub.GetClientFactory(ctx)
|
||||
if err != nil {
|
||||
|
|
@ -137,8 +156,8 @@ func createFederationHostFromAP(ctx context.Context, actorID fm.ActorID) (*forge
|
|||
return &result, nil
|
||||
}
|
||||
|
||||
func fetchUserFromAP(ctx context.Context, personID fm.PersonID, federationHostID int64) (*user.User, *user.FederatedUser, error) {
|
||||
actionsUser := user.NewAPServerActor()
|
||||
func fetchUserFromAP(ctx context.Context, personID fm.PersonID, federationHost *forgefed.FederationHost) (*user_model.User, *user_model.FederatedUser, error) {
|
||||
actionsUser := user_model.NewAPServerActor()
|
||||
clientFactory, err := activitypub.GetClientFactory(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
|
|
@ -164,16 +183,18 @@ func fetchUserFromAP(ctx context.Context, personID fm.PersonID, federationHostID
|
|||
return nil, nil, err
|
||||
}
|
||||
|
||||
log.Info("Fetched valid person from distant server: %q", person)
|
||||
|
||||
localFqdn, err := url.ParseRequestURI(setting.AppURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
personIDFromActor, err := fm.NewPersonID(person.ID.GetLink().String(), string(federationHost.NodeInfo.SoftwareName))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
email := fmt.Sprintf("f%v@%v", uuid.New().String(), localFqdn.Hostname())
|
||||
loginName := personID.AsLoginName()
|
||||
name := fmt.Sprintf("@%v%v", person.PreferredUsername.String(), personID.HostSuffix())
|
||||
loginName := personIDFromActor.AsLoginName()
|
||||
name := fmt.Sprintf("@%v%v", person.PreferredUsername.String(), personIDFromActor.HostSuffix())
|
||||
fullName := person.Name.String()
|
||||
|
||||
if len(person.Name) == 0 {
|
||||
|
|
@ -190,7 +211,7 @@ func fetchUserFromAP(ctx context.Context, personID fm.PersonID, federationHostID
|
|||
return nil, nil, err
|
||||
}
|
||||
|
||||
newUser := user.User{
|
||||
newUser := user_model.User{
|
||||
LowerName: strings.ToLower(name),
|
||||
Name: name,
|
||||
FullName: fullName,
|
||||
|
|
@ -201,15 +222,15 @@ func fetchUserFromAP(ctx context.Context, personID fm.PersonID, federationHostID
|
|||
Salt: "",
|
||||
PasswdHashAlgo: "",
|
||||
LoginName: loginName,
|
||||
Type: user.UserTypeActivityPubUser,
|
||||
Type: user_model.UserTypeActivityPubUser,
|
||||
IsAdmin: false,
|
||||
}
|
||||
|
||||
federatedUser := user.FederatedUser{
|
||||
ExternalID: personID.ID,
|
||||
FederationHostID: federationHostID,
|
||||
federatedUser := user_model.FederatedUser{
|
||||
ExternalID: personIDFromActor.ID,
|
||||
FederationHostID: federationHost.ID,
|
||||
InboxPath: inbox.Path,
|
||||
NormalizedOriginalURL: personID.AsURI(),
|
||||
NormalizedOriginalURL: personIDFromActor.AsURI(),
|
||||
KeyID: sql.NullString{
|
||||
String: person.PublicKey.ID.String(),
|
||||
Valid: true,
|
||||
|
|
@ -220,20 +241,6 @@ func fetchUserFromAP(ctx context.Context, personID fm.PersonID, federationHostID
|
|||
},
|
||||
}
|
||||
|
||||
log.Info("Fetched person's %q federatedUser from distant server: %q", person, federatedUser)
|
||||
log.Trace("Fetched person's %v federatedUser from distant server: %s", person, federatedUser.LogString())
|
||||
return &newUser, &federatedUser, nil
|
||||
}
|
||||
|
||||
func createUserFromAP(ctx context.Context, personID fm.PersonID, federationHostID int64) (*user.User, *user.FederatedUser, error) {
|
||||
newUser, federatedUser, err := fetchUserFromAP(ctx, personID, federationHostID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
err = user.CreateFederatedUser(ctx, newUser, federatedUser)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
log.Info("Created federatedUser: %q", federatedUser)
|
||||
return newUser, federatedUser, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,11 +82,6 @@ func FindOrCreateFederatedUserKey(ctx context.Context, keyID string) (pubKey any
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
_, err = forgefed.GetFederationHost(ctx, federatedUser.FederationHostID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if federatedUser.PublicKey.Valid {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@
|
|||
{{end}}
|
||||
<link rel="icon" href="{{AssetUrlPrefix}}/img/favicon.svg" type="image/svg+xml">
|
||||
<link rel="alternate icon" href="{{AssetUrlPrefix}}/img/favicon.png" type="image/png">
|
||||
{{if and FederationEnabled .PageIsUserProfile .ContextUser .ContextUser.IsIndividual}}
|
||||
<link rel="alternate" type="application/activity+json" href="{{.ContextUser.APActorID}}">
|
||||
{{end}}
|
||||
{{template "base/head_script" .}}
|
||||
{{template "shared/user/mention_highlight" .}}
|
||||
{{template "base/head_opengraph" .}}
|
||||
|
|
|
|||
42
templates/swagger/v1_json.tmpl
generated
42
templates/swagger/v1_json.tmpl
generated
|
|
@ -19583,6 +19583,38 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/user/activitypub/follow": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"user"
|
||||
],
|
||||
"summary": "Follow a remote activitypub account",
|
||||
"operationId": "userCurrentActivityPubFollow",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/APRemoteFollowOption"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"$ref": "#/responses/empty"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/responses/unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/responses/forbidden"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/responses/notFound"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/user/applications/oauth2": {
|
||||
"get": {
|
||||
"produces": [
|
||||
|
|
@ -22279,6 +22311,16 @@
|
|||
},
|
||||
"x-go-package": "forgejo.org/services/context"
|
||||
},
|
||||
"APRemoteFollowOption": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": {
|
||||
"type": "string",
|
||||
"x-go-name": "Target"
|
||||
}
|
||||
},
|
||||
"x-go-package": "forgejo.org/modules/structs"
|
||||
},
|
||||
"AccessToken": {
|
||||
"type": "object",
|
||||
"title": "AccessToken represents an API access token.",
|
||||
|
|
|
|||
26
templates/user/dashboard/ap_feed.tmpl
Normal file
26
templates/user/dashboard/ap_feed.tmpl
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<div id="activity-feed" class="flex-list">
|
||||
{{range .FollowingFeeds}}
|
||||
<div class="flex-item">
|
||||
{{if not (eq .Actor.ID 0)}}
|
||||
<div class="flex-item-leading">
|
||||
{{ctx.AvatarUtils.Avatar . 48}}
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="flex-item-main">
|
||||
<div class="flex-item-title">
|
||||
<a class="text muted" href="{{.ActorURI}}">{{.Actor.Name}}</a>
|
||||
</div>
|
||||
<div class="render-content markup">
|
||||
{{.NoteContent | SanitizeHTMLStrict}}
|
||||
</div>
|
||||
{{if .NoteURL}}
|
||||
<div class="flex-item-footer">
|
||||
<span class="flex-text-inline">{{svg "octicon-calendar"}}{{ctx.Locale.Tr "user.activitypub_feed.posted_on" (DateUtils.TimeSince .Created)}}</span>
|
||||
<a class="flex-text-inline" href="{{.NoteURL}}">{{svg "octicon-link-external"}}{{ctx.Locale.Tr "user.activitypub_feed.original_source"}}</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{template "base/paginate" .}}
|
||||
</div>
|
||||
6
templates/user/dashboard/ap_feed_guide.tmpl
Normal file
6
templates/user/dashboard/ap_feed_guide.tmpl
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<div id="empty-ap-feed" class="tw-text-center tw-p-8">
|
||||
{{svg "octicon-people" 64 "tw-text-placeholder-text"}}
|
||||
<h2>{{ctx.Locale.Tr "user.activitypub_feed.no_activity"}}</h2>
|
||||
<p class="help">{{ctx.Locale.Tr "user.activitypub_feed.is_empty"}}</p>
|
||||
<p class="help">{{ctx.Locale.Tr "user.activitypub_feed.hint"}}</p>
|
||||
</div>
|
||||
|
|
@ -41,6 +41,11 @@
|
|||
{{svg "octicon-rss"}} {{ctx.Locale.Tr "user.activity"}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{if and FederationEnabled (eq .SignedUserID .ContextUser.ID)}}
|
||||
<a class="{{if eq .TabName "feed"}}active {{end}}item" href="{{.ContextUser.HomeLink}}?tab=feed">
|
||||
{{svg "fediverse-small"}} {{ctx.Locale.Tr "user.activitypub_feed.feed"}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{if not .DisableStars}}
|
||||
<a class="{{if eq .TabName "stars"}}active {{end}}item" href="{{.ContextUser.HomeLink}}?tab=stars">
|
||||
{{svg "octicon-star"}} {{ctx.Locale.Tr "user.starred"}}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,14 @@
|
|||
{{.ProfileReadme}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{else if and FederationEnabled (eq .TabName "feed")}}
|
||||
{{if eq .SignedUserID .ContextUser.ID}}
|
||||
{{if .FollowingFeeds}}
|
||||
{{template "user/dashboard/ap_feed" .}}
|
||||
{{else}}
|
||||
{{template "user/dashboard/ap_feed_guide" .}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{else}}
|
||||
{{template "shared/repo_search" .}}
|
||||
{{template "explore/repo_list" .}}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"forgejo.org/models/db"
|
||||
"forgejo.org/models/unittest"
|
||||
user_model "forgejo.org/models/user"
|
||||
"forgejo.org/modules/activitypub"
|
||||
|
|
@ -26,7 +27,6 @@ import (
|
|||
// Flow of this test is documented at: https://codeberg.org/forgejo-contrib/federation/src/branch/main/doc/user-activity-following.md
|
||||
func TestActivityPubPersonInboxFollow(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.Federation.Enabled, true)()
|
||||
defer test.MockVariableValue(&setting.Federation.SignatureEnforced, false)()
|
||||
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
|
||||
|
||||
federation.Init()
|
||||
|
|
@ -40,6 +40,7 @@ func TestActivityPubPersonInboxFollow(t *testing.T) {
|
|||
|
||||
distantURL := federatedSrv.URL
|
||||
distantUser15URL := fmt.Sprintf("%s/api/v1/activitypub/user-id/15", distantURL)
|
||||
distantUser15AliasURL := fmt.Sprintf("%s/api/v1/activitypub/user-id/alias15", distantURL)
|
||||
|
||||
localUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
localUser2URL := localUrl.JoinPath("/api/v1/activitypub/user-id/2").String()
|
||||
|
|
@ -52,14 +53,16 @@ func TestActivityPubPersonInboxFollow(t *testing.T) {
|
|||
`{"type":"Follow",`+
|
||||
`"actor":"%s",`+
|
||||
`"object":"%s"}`,
|
||||
distantUser15URL,
|
||||
distantUser15AliasURL,
|
||||
localUser2URL,
|
||||
)
|
||||
|
||||
cf, err := activitypub.NewClientFactoryWithTimeout(60 * time.Second)
|
||||
require.NoError(t, err)
|
||||
c, err := cf.WithKeysDirect(ctx, mock.ApActor.PrivKey,
|
||||
mock.ApActor.KeyID(federatedSrv.URL))
|
||||
|
||||
c, err := cf.WithKeysDirect(ctx, mock.ApActor.PrivKey, mock.ApActor.KeyID(federatedSrv.URL))
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := c.Post(followActivity, localUser2Inbox)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
|
||||
|
|
@ -94,9 +97,12 @@ func TestActivityPubPersonInboxFollow(t *testing.T) {
|
|||
distantUser15URL,
|
||||
localUser2URL,
|
||||
)
|
||||
|
||||
c, err = cf.WithKeysDirect(ctx, mock.ApActor.PrivKey,
|
||||
mock.ApActor.KeyID(federatedSrv.URL))
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err = c.Post(undoFollowActivity, localUser2Inbox)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
|
||||
|
|
@ -110,3 +116,44 @@ func TestActivityPubPersonInboxFollow(t *testing.T) {
|
|||
)
|
||||
})
|
||||
}
|
||||
|
||||
func TestActivityPubFollowRefollow(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.Federation.Enabled, true)()
|
||||
defer test.MockVariableValue(&setting.Federation.SignatureEnforced, false)()
|
||||
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
|
||||
|
||||
require.NoError(t, federation.Init())
|
||||
|
||||
mock := test.NewFederationServerMock()
|
||||
federatedSrv := mock.DistantServer(t)
|
||||
defer federatedSrv.Close()
|
||||
|
||||
onApplicationRun(t, func(t *testing.T, localUrl *url.URL) {
|
||||
defer test.MockVariableValue(&setting.AppURL, localUrl.String())()
|
||||
|
||||
distantURL := federatedSrv.URL
|
||||
distantUser15AliasURL := fmt.Sprintf("%s/api/v1/activitypub/user-id/alias15", distantURL)
|
||||
|
||||
localUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
localUser2URL := localUrl.JoinPath("/api/v1/activitypub/user-id/2")
|
||||
localUser2Inbox := localUrl.JoinPath("/api/v1/activitypub/user-id/2/inbox")
|
||||
|
||||
ctx := t.Context()
|
||||
|
||||
var follow user_model.FederatedUserFollower
|
||||
has, err := db.GetEngine(ctx).Get(&follow)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, has)
|
||||
|
||||
require.NoError(t, mock.FollowActorUnsigned(federatedSrv.URL, 15, *localUser2URL, *localUser2Inbox))
|
||||
|
||||
has, err = db.GetEngine(ctx).Get(&follow)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, has)
|
||||
assert.Equal(t, int64(2), follow.FollowedUserID)
|
||||
|
||||
apiCtx, _ := contexttest.MockAPIContext(t, localUser2Inbox.String())
|
||||
err = federation.FollowRemoteActor(apiCtx, localUser, distantUser15AliasURL)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"forgejo.org/models/activities"
|
||||
auth_model "forgejo.org/models/auth"
|
||||
"forgejo.org/models/unittest"
|
||||
user_model "forgejo.org/models/user"
|
||||
|
|
@ -26,9 +27,80 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestActivityPubPersonInboxNoteFromDistant(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.Federation.Enabled, true)()
|
||||
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
|
||||
|
||||
federation.Init()
|
||||
|
||||
mock := test.NewFederationServerMock()
|
||||
federatedSrv := mock.DistantServer(t)
|
||||
defer federatedSrv.Close()
|
||||
|
||||
onApplicationRun(t, func(t *testing.T, localUrl *url.URL) {
|
||||
defer test.MockVariableValue(&setting.AppURL, localUrl.String())()
|
||||
|
||||
distantURL := federatedSrv.URL
|
||||
distantUser15URL := fmt.Sprintf("%s/api/v1/activitypub/user-id/15", distantURL)
|
||||
|
||||
localUser2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||
localUser2URL := localUrl.JoinPath("/api/v1/activitypub/user-id/2").String()
|
||||
localUser2Inbox := localUrl.JoinPath("/api/v1/activitypub/user-id/2/inbox").String()
|
||||
localSession2 := loginUser(t, localUser2.LoginName)
|
||||
localSecssion2Token := getTokenForLoggedInUser(t, localSession2, auth_model.AccessTokenScopeWriteUser)
|
||||
|
||||
// view own empty feed on web UI
|
||||
feedPage := NewHTMLParser(t, localSession2.MakeRequest(t, NewRequest(t, "GET", "/user2?tab=feed"), http.StatusOK).Body)
|
||||
feedPage.AssertElement(t, "#empty-ap-feed", true)
|
||||
|
||||
// follow (local follows distant)
|
||||
req := NewRequestWithJSON(t, "POST",
|
||||
"/api/v1/user/activitypub/follow",
|
||||
&structs.APRemoteFollowOption{
|
||||
Target: distantUser15URL,
|
||||
}).
|
||||
AddTokenAuth(localSecssion2Token)
|
||||
MakeRequest(t, req, http.StatusNoContent)
|
||||
|
||||
// send note (distant -> local)
|
||||
distantNoteURL := fmt.Sprintf("%s/api/v1/activitypub/note/104", distantURL)
|
||||
|
||||
userActivity := fmt.Appendf(
|
||||
[]byte{},
|
||||
`{"type":"Create",`+
|
||||
`"actor":"%s",`+
|
||||
`"to": ["https://www.w3.org/ns/activitystreams#Public"],`+
|
||||
`"cc": ["%s"],`+
|
||||
`"object": {"type":"Note","content":"The Content!",`+
|
||||
`"url":"%s"}}`,
|
||||
distantUser15URL,
|
||||
localUser2URL,
|
||||
distantNoteURL,
|
||||
)
|
||||
|
||||
ctx, _ := contexttest.MockAPIContext(t, localUser2Inbox)
|
||||
cf, err := activitypub.NewClientFactoryWithTimeout(60 * time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
c, err := cf.WithKeysDirect(ctx, mock.ApActor.PrivKey, mock.ApActor.KeyID(federatedSrv.URL))
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := c.Post(userActivity, localUser2Inbox)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
|
||||
|
||||
// check whether user activity exists in local instance
|
||||
unittest.AssertExistsAndLoadBean(t, &activities.FederatedUserActivity{NoteURL: distantNoteURL})
|
||||
|
||||
// view own non-empty feed on web UI
|
||||
feedPage = NewHTMLParser(t, localSession2.MakeRequest(t, NewRequest(t, "GET", "/user2?tab=feed"), http.StatusOK).Body)
|
||||
feedPage.AssertElement(t, "#empty-ap-feed", false)
|
||||
})
|
||||
}
|
||||
|
||||
func TestActivityPubPersonInboxNoteToDistant(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.Federation.Enabled, true)()
|
||||
defer test.MockVariableValue(&setting.Federation.SignatureEnforced, false)()
|
||||
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
|
||||
|
||||
federation.Init()
|
||||
|
|
@ -60,14 +132,17 @@ func TestActivityPubPersonInboxNoteToDistant(t *testing.T) {
|
|||
distantUser15URL,
|
||||
localUser2URL,
|
||||
)
|
||||
|
||||
ctx, _ := contexttest.MockAPIContext(t, localUser2Inbox)
|
||||
cf, err := activitypub.NewClientFactoryWithTimeout(60 * time.Second)
|
||||
require.NoError(t, err)
|
||||
c, err := cf.WithKeysDirect(ctx, mock.ApActor.PrivKey,
|
||||
mock.ApActor.KeyID(federatedSrv.URL))
|
||||
|
||||
c, err := cf.WithKeysDirect(ctx, mock.ApActor.PrivKey, mock.ApActor.KeyID(federatedSrv.URL))
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := c.Post(followActivity, localUser2Inbox)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
|
||||
|
||||
// local action which triggers a user activity
|
||||
|
|
@ -87,9 +162,11 @@ func TestActivityPubPersonInboxNoteToDistant(t *testing.T) {
|
|||
// distant request activity & activity note
|
||||
localUser2ActivityNote := fmt.Sprintf("%v/activities/1", localUser2URL)
|
||||
localUser2Activity := fmt.Sprintf("%v/activities/1/activity", localUser2URL)
|
||||
|
||||
resp, err = c.Get(localUser2ActivityNote)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
resp, err = c.Get(localUser2Activity)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
|
|
|||
55
tests/integration/api_user_follow_federation_test.go
Normal file
55
tests/integration/api_user_follow_federation_test.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
auth_model "forgejo.org/models/auth"
|
||||
"forgejo.org/models/forgefed"
|
||||
"forgejo.org/models/unittest"
|
||||
"forgejo.org/models/user"
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/modules/structs"
|
||||
"forgejo.org/modules/test"
|
||||
"forgejo.org/routers"
|
||||
"forgejo.org/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestActivityPubFollowFederated(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
defer test.MockVariableValue(&setting.Federation.Enabled, true)()
|
||||
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
|
||||
|
||||
mock := test.NewFederationServerMock()
|
||||
federatedSrv := mock.DistantServer(t)
|
||||
defer federatedSrv.Close()
|
||||
|
||||
localUser10Name := "user10"
|
||||
localSession10 := loginUser(t, localUser10Name)
|
||||
localSecssion10Token := getTokenForLoggedInUser(t, localSession10, auth_model.AccessTokenScopeWriteUser)
|
||||
|
||||
distantURL := federatedSrv.URL
|
||||
distantUser15URL := fmt.Sprintf("%s/api/v1/activitypub/user-id/15", distantURL)
|
||||
|
||||
// local user follow distant
|
||||
req := NewRequestWithJSON(t, "POST",
|
||||
"/api/v1/user/activitypub/follow",
|
||||
&structs.APRemoteFollowOption{
|
||||
Target: distantUser15URL,
|
||||
}).
|
||||
AddTokenAuth(localSecssion10Token)
|
||||
MakeRequest(t, req, http.StatusNoContent)
|
||||
|
||||
// check: federated actors now exist local
|
||||
federationHost := unittest.AssertExistsAndLoadBean(t, &forgefed.FederationHost{HostFqdn: "127.0.0.1"})
|
||||
unittest.AssertExistsAndLoadBean(t, &user.FederatedUser{ExternalID: "15", FederationHostID: federationHost.ID})
|
||||
|
||||
// check: follow request arrived at distant
|
||||
assert.Contains(t, mock.LastPost, "\"object\":\"http://DISTANT_FEDERATION_HOST/api/v1/activitypub/user-id/15\"")
|
||||
}
|
||||
53
tests/integration/federation_home_template_test.go
Normal file
53
tests/integration/federation_home_template_test.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/modules/test"
|
||||
"forgejo.org/routers"
|
||||
"forgejo.org/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
func getLinks(t *testing.T, url string) []*html.Node {
|
||||
req := NewRequest(t, "GET", url)
|
||||
resp := MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
htmlDoc := NewHTMLParser(t, resp.Body)
|
||||
links := htmlDoc.doc.Find("link[type=\"application/activity+json\"]").Nodes
|
||||
|
||||
return links
|
||||
}
|
||||
|
||||
func TestFederationBaseHead(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
|
||||
|
||||
t.Run("Federation disabled", func(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.Federation.Enabled, false)()
|
||||
|
||||
links := getLinks(t, "/user1")
|
||||
assert.Empty(t, links)
|
||||
})
|
||||
|
||||
t.Run("Federation enabled", func(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.Federation.Enabled, true)()
|
||||
|
||||
links := getLinks(t, "/user1")
|
||||
assert.Len(t, links, 1)
|
||||
})
|
||||
|
||||
t.Run("Organization", func(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.Federation.Enabled, true)()
|
||||
|
||||
links := getLinks(t, "/org3")
|
||||
assert.Empty(t, links)
|
||||
})
|
||||
}
|
||||
31
web_src/svg/fediverse-small.svg
Normal file
31
web_src/svg/fediverse-small.svg
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 6.3499999 6.35"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<metadata
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
>
|
||||
<rdf:RDF>
|
||||
<cc:Work rdf:about="https://codeberg.org/forgejo/forgejo/src/web_src/svg/fediverse-small.svg">
|
||||
<dc:title>Forgejo small Fediverse icon</dc:title>
|
||||
<cc:attributionName>The Forgejo Authors</cc:attributionName>
|
||||
<cc:license>MIT</cc:license>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<path
|
||||
style="stroke-linecap:round"
|
||||
d="M 3.5898438,1.1601562 A 0.203686,0.203686 0 0 0 3.3496094,1.3203125 0.203686,0.203686 0 0 0 3.5078125,1.5605469 c 0.1072344,0.021943 0.2100638,0.054028 0.3085938,0.095703 0.1970601,0.08335 0.3741719,0.2042501 0.5234374,0.3535156 0.2985315,0.2985315 0.4843751,0.7096169 0.484375,1.1660157 0,0.2281995 -0.04751,0.4435652 -0.1308593,0.640625 a 0.203686,0.203686 0 0 0 0.109375,0.2675781 0.203686,0.203686 0 0 0 0.265625,-0.109375 C 5.1724517,3.7285087 5.2304688,3.4590205 5.2304687,3.1757813 5.2304687,2.6093024 5.0006974,2.0924942 4.6289062,1.7207031 4.4430106,1.5348075 4.2207091,1.3853417 3.9746094,1.28125 3.8515589,1.2292038 3.7237416,1.1875557 3.5898438,1.1601562 Z M 1.546875,2.265625 A 0.203686,0.203686 0 0 0 1.28125,2.375 C 1.1771594,2.6210999 1.1191406,2.8925415 1.1191406,3.1757813 c 0,0.5664787 0.2297714,1.0813338 0.6015625,1.4531249 0.1858956,0.1858957 0.408197,0.3353614 0.6542969,0.4394532 0.1230501,0.052046 0.2528205,0.093694 0.3867188,0.1210937 A 0.203686,0.203686 0 0 0 3.0019531,5.03125 0.203686,0.203686 0 0 0 2.8417969,4.7890625 C 2.7345627,4.7671193 2.6317332,4.7350342 2.5332031,4.6933594 2.3361428,4.6100096 2.1590312,4.4891093 2.0097656,4.3398437 1.7112341,4.0413123 1.5273438,3.6321799 1.5273437,3.1757813 c 0,-0.2281991 0.045557,-0.4455169 0.1289063,-0.6425782 A 0.203686,0.203686 0 0 0 1.546875,2.265625 Z"
|
||||
transform="matrix(1.2870397,0,0,1.3110209,-0.91132678,-0.98749822)" />
|
||||
<path
|
||||
d="m 1.984375,0.921875 c -0.467452,2e-8 -0.8496094,0.3841105 -0.8496094,0.8515625 0,0.467452 0.3821574,0.8496094 0.8496094,0.8496094 0.467452,0 0.8496094,-0.3821574 0.8496094,-0.8496094 0,-0.467452 -0.3821574,-0.85156248 -0.8496094,-0.8515625 z m 0,0.3789062 c 0.2631744,10e-8 0.4726562,0.2094819 0.4726563,0.4726563 -1e-7,0.2631744 -0.2094819,0.4726562 -0.4726563,0.4726563 -0.2631744,-1e-7 -0.4726562,-0.2094819 -0.4726563,-0.4726563 10e-8,-0.2631744 0.2094819,-0.4726562 0.4726563,-0.4726563 z"
|
||||
transform="matrix(1.3999375,0,0,1.4000066,-1.0582509,-1.0265909)" />
|
||||
<path
|
||||
d="m 4.6308594,3.7050781 c -0.6544307,10e-8 -1.1914062,0.5350214 -1.1914063,1.1894531 10e-8,0.6544318 0.5369756,1.1914062 1.1914063,1.1914063 0.6544307,0 1.1894531,-0.5369745 1.1894531,-1.1914063 0,-0.6544317 -0.5350224,-1.1894531 -1.1894531,-1.1894531 z m 0,0.5273438 c 0.3684464,0 0.6601562,0.2936593 0.6601562,0.6621093 0,0.3684501 -0.2917098,0.6621094 -0.6601562,0.6621094 -0.3684464,0 -0.6621094,-0.2936593 -0.6621094,-0.6621094 0,-0.36845 0.293663,-0.6621093 0.6621094,-0.6621093 z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
Loading…
Add table
Add a link
Reference in a new issue