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:
famfo 2026-04-12 03:31:03 +02:00 committed by Gusted
parent 9de142eb7f
commit fd28fd896b
26 changed files with 599 additions and 84 deletions

View file

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

View file

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

View file

@ -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
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"
)
// ForgeFollow activity data type
// swagger:model
type ForgeInbox struct {
// swagger:ignore
ap.InboxStream
}

View file

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

View file

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

View file

@ -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",

View 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
View 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.

View file

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

View file

@ -19,6 +19,9 @@ type swaggerParameterBodies struct {
// in:body
ForgeLike ffed.ForgeLike
// in:body
APRemoteFollowOption api.APRemoteFollowOption `json:"body"`
// in:body
AddCollaboratorOption api.AddCollaboratorOption

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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.",

View 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>

View 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>

View file

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

View file

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

View file

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

View file

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

View 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\"")
}

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

View 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