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/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/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/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/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/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_useractivity_test.go b/tests/integration/api_activitypub_person_inbox_useractivity_test.go index b1c28555e0..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,6 +27,78 @@ 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(&testWebRoutes, routers.NormalRoutes())() 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