diff --git a/.deadcode-out b/.deadcode-out index 0c44d2bdfd..a42b499a9f 100644 --- a/.deadcode-out +++ b/.deadcode-out @@ -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 diff --git a/models/activities/federated_user_activity.go b/models/activities/federated_user_activity.go index 1ff3a855d0..a9f509e8f7 100644 --- a/models/activities/federated_user_activity.go +++ b/models/activities/federated_user_activity.go @@ -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) } diff --git a/models/user/federated_user.go b/models/user/federated_user.go index d2a9c34c9e..8199a9cd22 100644 --- a/models/user/federated_user.go +++ b/models/user/federated_user.go @@ -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 "" + } + + return fmt.Sprintf( + "", + federatedUser.ID, + federatedUser.UserID, + federatedUser.ExternalID, + federatedUser.NormalizedOriginalURL, + federatedUser.InboxPath, + ) +} diff --git a/modules/forgefed/inbox.go b/modules/forgefed/inbox.go new file mode 100644 index 0000000000..c02aedd38c --- /dev/null +++ b/modules/forgefed/inbox.go @@ -0,0 +1,15 @@ +// Copyright 2024, 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package forgefed + +import ( + ap "github.com/go-ap/activitypub" +) + +// ForgeFollow activity data type +// swagger:model +type ForgeInbox struct { + // swagger:ignore + ap.InboxStream +} diff --git a/modules/templates/helper.go b/modules/templates/helper.go index a6235c37ba..44f5da24cb 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -52,16 +52,17 @@ func NewFuncMap() template.FuncMap { // ----------------------------------------------------------------- // html/template related functions - "dict": dict, // it's lowercase because this name has been widely used. Our other functions should have uppercase names. - "Eval": Eval, - "TrustHTML": TrustHTML, - "HTMLFormat": HTMLFormat, - "HTMLEscape": HTMLEscape, - "QueryEscape": QueryEscape, - "JSEscape": JSEscapeSafe, - "SanitizeHTML": SanitizeHTML, - "URLJoin": util.URLJoin, - "DotEscape": DotEscape, + "dict": dict, // it's lowercase because this name has been widely used. Our other functions should have uppercase names. + "Eval": Eval, + "TrustHTML": TrustHTML, + "HTMLFormat": HTMLFormat, + "HTMLEscape": HTMLEscape, + "QueryEscape": QueryEscape, + "JSEscape": JSEscapeSafe, + "SanitizeHTML": SanitizeHTML, + "SanitizeHTMLStrict": SanitizeHTMLStrict, + "URLJoin": util.URLJoin, + "DotEscape": DotEscape, "PathEscape": url.PathEscape, "PathEscapeSegments": util.PathEscapeSegments, @@ -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: diff --git a/modules/test/distant_federation_server_mock.go b/modules/test/distant_federation_server_mock.go index ea8a69e9b4..abbc82c196 100644 --- a/modules/test/distant_federation_server_mock.go +++ b/modules/test/distant_federation_server_mock.go @@ -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 } diff --git a/options/locale_next/locale_en-US.json b/options/locale_next/locale_en-US.json index 3195d6f6f1..c0c73ec860 100644 --- a/options/locale_next/locale_en-US.json +++ b/options/locale_next/locale_en-US.json @@ -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", diff --git a/public/assets/img/svg/fediverse-small.svg b/public/assets/img/svg/fediverse-small.svg new file mode 100644 index 0000000000..90f9a79ef5 --- /dev/null +++ b/public/assets/img/svg/fediverse-small.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/release-notes/10380.md b/release-notes/10380.md new file mode 100644 index 0000000000..04c1b7c665 --- /dev/null +++ b/release-notes/10380.md @@ -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. diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index d0b8531aea..efd10b567f 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -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() { diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 1cc77b319a..7df4d42c34 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -19,6 +19,9 @@ type swaggerParameterBodies struct { // in:body ForgeLike ffed.ForgeLike + // in:body + APRemoteFollowOption api.APRemoteFollowOption `json:"body"` + // in:body AddCollaboratorOption api.AddCollaboratorOption diff --git a/routers/api/v1/user/follower.go b/routers/api/v1/user/follower.go index 643ad49b80..508632a6e7 100644 --- a/routers/api/v1/user/follower.go +++ b/routers/api/v1/user/follower.go @@ -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) +} diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go index 2a3893f80e..ec3cb4a5d3 100644 --- a/routers/web/user/profile.go +++ b/routers/web/user/profile.go @@ -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 { diff --git a/services/federation/federation_service.go b/services/federation/federation_service.go index d0336881a8..04cc4e878a 100644 --- a/services/federation/federation_service.go +++ b/services/federation/federation_service.go @@ -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 -} diff --git a/services/federation/signature_service.go b/services/federation/signature_service.go index 25e4e270bc..bd40a37beb 100644 --- a/services/federation/signature_service.go +++ b/services/federation/signature_service.go @@ -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 { diff --git a/templates/base/head.tmpl b/templates/base/head.tmpl index d31d25db46..8b4f661b57 100644 --- a/templates/base/head.tmpl +++ b/templates/base/head.tmpl @@ -19,6 +19,9 @@ {{end}} +{{if and FederationEnabled .PageIsUserProfile .ContextUser .ContextUser.IsIndividual}} + +{{end}} {{template "base/head_script" .}} {{template "shared/user/mention_highlight" .}} {{template "base/head_opengraph" .}} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index e9b10e1e52..8ca012823a 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -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.", diff --git a/templates/user/dashboard/ap_feed.tmpl b/templates/user/dashboard/ap_feed.tmpl new file mode 100644 index 0000000000..1127026139 --- /dev/null +++ b/templates/user/dashboard/ap_feed.tmpl @@ -0,0 +1,26 @@ +
+ {{range .FollowingFeeds}} +
+ {{if not (eq .Actor.ID 0)}} +
+ {{ctx.AvatarUtils.Avatar . 48}} +
+ {{end}} +
+ +
+ {{.NoteContent | SanitizeHTMLStrict}} +
+ {{if .NoteURL}} + + {{end}} +
+
+ {{end}} + {{template "base/paginate" .}} +
diff --git a/templates/user/dashboard/ap_feed_guide.tmpl b/templates/user/dashboard/ap_feed_guide.tmpl new file mode 100644 index 0000000000..a3a29abbed --- /dev/null +++ b/templates/user/dashboard/ap_feed_guide.tmpl @@ -0,0 +1,6 @@ +
+ {{svg "octicon-people" 64 "tw-text-placeholder-text"}} +

{{ctx.Locale.Tr "user.activitypub_feed.no_activity"}}

+

{{ctx.Locale.Tr "user.activitypub_feed.is_empty"}}

+

{{ctx.Locale.Tr "user.activitypub_feed.hint"}}

+
diff --git a/templates/user/overview/header.tmpl b/templates/user/overview/header.tmpl index ea5d8052f4..cc306cc571 100644 --- a/templates/user/overview/header.tmpl +++ b/templates/user/overview/header.tmpl @@ -41,6 +41,11 @@ {{svg "octicon-rss"}} {{ctx.Locale.Tr "user.activity"}} {{end}} + {{if and FederationEnabled (eq .SignedUserID .ContextUser.ID)}} + + {{svg "fediverse-small"}} {{ctx.Locale.Tr "user.activitypub_feed.feed"}} + + {{end}} {{if not .DisableStars}} {{svg "octicon-star"}} {{ctx.Locale.Tr "user.starred"}} diff --git a/templates/user/profile.tmpl b/templates/user/profile.tmpl index 30210a1775..6fd533767f 100644 --- a/templates/user/profile.tmpl +++ b/templates/user/profile.tmpl @@ -58,6 +58,14 @@ {{.ProfileReadme}} {{end}} + {{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" .}} diff --git a/tests/integration/api_activitypub_person_inbox_follow_test.go b/tests/integration/api_activitypub_person_inbox_follow_test.go index 5a0b452447..f31e7e9011 100644 --- a/tests/integration/api_activitypub_person_inbox_follow_test.go +++ b/tests/integration/api_activitypub_person_inbox_follow_test.go @@ -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) + }) +} diff --git a/tests/integration/api_activitypub_person_inbox_useractivity_test.go b/tests/integration/api_activitypub_person_inbox_useractivity_test.go index 55fd62a3fe..fa51050045 100644 --- a/tests/integration/api_activitypub_person_inbox_useractivity_test.go +++ b/tests/integration/api_activitypub_person_inbox_useractivity_test.go @@ -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) diff --git a/tests/integration/api_user_follow_federation_test.go b/tests/integration/api_user_follow_federation_test.go new file mode 100644 index 0000000000..e3dc72b264 --- /dev/null +++ b/tests/integration/api_user_follow_federation_test.go @@ -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\"") +} diff --git a/tests/integration/federation_home_template_test.go b/tests/integration/federation_home_template_test.go new file mode 100644 index 0000000000..806fe31b7b --- /dev/null +++ b/tests/integration/federation_home_template_test.go @@ -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) + }) +} diff --git a/web_src/svg/fediverse-small.svg b/web_src/svg/fediverse-small.svg new file mode 100644 index 0000000000..43baee480e --- /dev/null +++ b/web_src/svg/fediverse-small.svg @@ -0,0 +1,31 @@ + + + + + + Forgejo small Fediverse icon + The Forgejo Authors + MIT + + + + + + + \ No newline at end of file