forked from mirrors/forgejo
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
|
Event.String
|
||||||
|
|
||||||
forgejo.org/modules/forgefed
|
forgejo.org/modules/forgefed
|
||||||
NewForgeFollow
|
|
||||||
NewForgeUndoLike
|
NewForgeUndoLike
|
||||||
ForgeUndoLike.UnmarshalJSON
|
ForgeUndoLike.UnmarshalJSON
|
||||||
ForgeUndoLike.Validate
|
ForgeUndoLike.Validate
|
||||||
|
|
@ -227,9 +226,6 @@ forgejo.org/routers/web/org
|
||||||
forgejo.org/services/context
|
forgejo.org/services/context
|
||||||
GetPrivateContext
|
GetPrivateContext
|
||||||
|
|
||||||
forgejo.org/services/federation
|
|
||||||
FollowRemoteActor
|
|
||||||
|
|
||||||
forgejo.org/services/notify
|
forgejo.org/services/notify
|
||||||
UnregisterNotifier
|
UnregisterNotifier
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ func GetFollowingFeeds(ctx context.Context, actorID int64, opts GetFollowingFeed
|
||||||
sess = db.SetSessionPagination(sess, &opts)
|
sess = db.SetSessionPagination(sess, &opts)
|
||||||
|
|
||||||
actions := make([]*FederatedUserActivity, 0, opts.PageSize)
|
actions := make([]*FederatedUserActivity, 0, opts.PageSize)
|
||||||
count, err := sess.FindAndCount(&actions)
|
count, err := sess.Desc("`federated_user_activity`.created").FindAndCount(&actions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, fmt.Errorf("FindAndCount: %w", err)
|
return nil, 0, fmt.Errorf("FindAndCount: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"forgejo.org/modules/validation"
|
"forgejo.org/modules/validation"
|
||||||
)
|
)
|
||||||
|
|
@ -42,3 +43,18 @@ func (federatedUser FederatedUser) Validate() []string {
|
||||||
result = append(result, validation.ValidateNotEmpty(federatedUser.InboxPath, "InboxPath")...)
|
result = append(result, validation.ValidateNotEmpty(federatedUser.InboxPath, "InboxPath")...)
|
||||||
return result
|
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
|
||||||
|
}
|
||||||
|
|
@ -52,16 +52,17 @@ func NewFuncMap() template.FuncMap {
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
// html/template related functions
|
// html/template related functions
|
||||||
"dict": dict, // it's lowercase because this name has been widely used. Our other functions should have uppercase names.
|
"dict": dict, // it's lowercase because this name has been widely used. Our other functions should have uppercase names.
|
||||||
"Eval": Eval,
|
"Eval": Eval,
|
||||||
"TrustHTML": TrustHTML,
|
"TrustHTML": TrustHTML,
|
||||||
"HTMLFormat": HTMLFormat,
|
"HTMLFormat": HTMLFormat,
|
||||||
"HTMLEscape": HTMLEscape,
|
"HTMLEscape": HTMLEscape,
|
||||||
"QueryEscape": QueryEscape,
|
"QueryEscape": QueryEscape,
|
||||||
"JSEscape": JSEscapeSafe,
|
"JSEscape": JSEscapeSafe,
|
||||||
"SanitizeHTML": SanitizeHTML,
|
"SanitizeHTML": SanitizeHTML,
|
||||||
"URLJoin": util.URLJoin,
|
"SanitizeHTMLStrict": SanitizeHTMLStrict,
|
||||||
"DotEscape": DotEscape,
|
"URLJoin": util.URLJoin,
|
||||||
|
"DotEscape": DotEscape,
|
||||||
|
|
||||||
"PathEscape": url.PathEscape,
|
"PathEscape": url.PathEscape,
|
||||||
"PathEscapeSegments": util.PathEscapeSegments,
|
"PathEscapeSegments": util.PathEscapeSegments,
|
||||||
|
|
@ -257,6 +258,10 @@ func SanitizeHTML(s string) template.HTML {
|
||||||
return template.HTML(markup.Sanitize(s))
|
return template.HTML(markup.Sanitize(s))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SanitizeHTMLStrict(s string) template.HTML {
|
||||||
|
return template.HTML(markup.SanitizeDescription(s))
|
||||||
|
}
|
||||||
|
|
||||||
func HTMLEscape(s any) template.HTML {
|
func HTMLEscape(s any) template.HTML {
|
||||||
switch v := s.(type) {
|
switch v := s.(type) {
|
||||||
case string:
|
case string:
|
||||||
|
|
|
||||||
|
|
@ -4,29 +4,38 @@
|
||||||
package test
|
package test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"forgejo.org/modules/util"
|
"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 {
|
type FederationServerMockPerson struct {
|
||||||
ID int64
|
ID int64
|
||||||
Name string
|
Name string
|
||||||
PubKey string
|
PubKey string
|
||||||
PrivKey string
|
PrivKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
type FederationServerMockRepository struct {
|
type FederationServerMockRepository struct {
|
||||||
ID int64
|
ID int64
|
||||||
}
|
}
|
||||||
type ApActorMock struct {
|
|
||||||
PrivKey string
|
|
||||||
PubKey string
|
|
||||||
}
|
|
||||||
type FederationServerMock struct {
|
type FederationServerMock struct {
|
||||||
ApActor ApActorMock
|
ApActor ApActorMock
|
||||||
Persons []FederationServerMockPerson
|
Persons []FederationServerMockPerson
|
||||||
|
|
@ -34,6 +43,35 @@ type FederationServerMock struct {
|
||||||
LastPost string
|
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 {
|
func NewFederationServerMockPerson(id int64, name string) FederationServerMockPerson {
|
||||||
priv, pub, _ := util.GenerateKeyPair(3072)
|
priv, pub, _ := util.GenerateKeyPair(3072)
|
||||||
return FederationServerMockPerson{
|
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)
|
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 {
|
func (p FederationServerMockPerson) marshal(host string) string {
|
||||||
return fmt.Sprintf(`{"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/v1"],`+
|
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",`+
|
`"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)
|
`"publicKeyPem":%[4]q}}`, host, p.ID, p.Name, p.PubKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewFederationServerMockRepository(id int64) FederationServerMockRepository {
|
||||||
|
return FederationServerMockRepository{
|
||||||
|
ID: id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func NewFederationServerMock() *FederationServerMock {
|
func NewFederationServerMock() *FederationServerMock {
|
||||||
return &FederationServerMock{
|
return &FederationServerMock{
|
||||||
ApActor: NewApActorMock(),
|
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")
|
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 {
|
func (mock *FederationServerMock) DistantServer(t *testing.T) *httptest.Server {
|
||||||
federatedRoutes := http.NewServeMux()
|
federatedRoutes := http.NewServeMux()
|
||||||
|
|
||||||
|
|
@ -122,6 +168,10 @@ func (mock *FederationServerMock) DistantServer(t *testing.T) *httptest.Server {
|
||||||
})
|
})
|
||||||
|
|
||||||
for _, person := range mock.Persons {
|
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),
|
federatedRoutes.HandleFunc(fmt.Sprintf("/api/v1/activitypub/user-id/%v", person.ID),
|
||||||
func(res http.ResponseWriter, req *http.Request) {
|
func(res http.ResponseWriter, req *http.Request) {
|
||||||
// curl -H "Accept: application/json" https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/2
|
// 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)
|
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("/",
|
federatedRoutes.HandleFunc("/",
|
||||||
func(res http.ResponseWriter, req *http.Request) {
|
func(res http.ResponseWriter, req *http.Request) {
|
||||||
t.Errorf("Unhandled %v request: %q", req.Method, req.URL.EscapedPath())
|
t.Errorf("Unhandled %v request: %q", req.Method, req.URL.EscapedPath())
|
||||||
})
|
})
|
||||||
|
|
||||||
federatedSrv := httptest.NewServer(federatedRoutes)
|
federatedSrv := httptest.NewServer(federatedRoutes)
|
||||||
|
|
||||||
return federatedSrv
|
return federatedSrv
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -261,6 +261,12 @@
|
||||||
"settings.access_token.admin_disabled": "Administrative permissions are disabled.",
|
"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",
|
"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",
|
"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.",
|
"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",
|
"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",
|
"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)
|
m.Delete("", user.Unfollow)
|
||||||
}, context.UserAssignmentAPI())
|
}, context.UserAssignmentAPI())
|
||||||
})
|
})
|
||||||
|
if setting.Federation.Enabled {
|
||||||
|
m.Group("/activitypub", func() {
|
||||||
|
m.Post("/follow", bind(api.APRemoteFollowOption{}), user.ActivityPubFollow)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// (admin:public_key scope)
|
// (admin:public_key scope)
|
||||||
m.Group("/keys", func() {
|
m.Group("/keys", func() {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ type swaggerParameterBodies struct {
|
||||||
// in:body
|
// in:body
|
||||||
ForgeLike ffed.ForgeLike
|
ForgeLike ffed.ForgeLike
|
||||||
|
|
||||||
|
// in:body
|
||||||
|
APRemoteFollowOption api.APRemoteFollowOption `json:"body"`
|
||||||
|
|
||||||
// in:body
|
// in:body
|
||||||
AddCollaboratorOption api.AddCollaboratorOption
|
AddCollaboratorOption api.AddCollaboratorOption
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,11 @@ import (
|
||||||
|
|
||||||
user_model "forgejo.org/models/user"
|
user_model "forgejo.org/models/user"
|
||||||
api "forgejo.org/modules/structs"
|
api "forgejo.org/modules/structs"
|
||||||
|
"forgejo.org/modules/web"
|
||||||
"forgejo.org/routers/api/v1/utils"
|
"forgejo.org/routers/api/v1/utils"
|
||||||
"forgejo.org/services/context"
|
"forgejo.org/services/context"
|
||||||
"forgejo.org/services/convert"
|
"forgejo.org/services/convert"
|
||||||
|
"forgejo.org/services/federation"
|
||||||
)
|
)
|
||||||
|
|
||||||
func responseAPIUsers(ctx *context.APIContext, users []*user_model.User) {
|
func responseAPIUsers(ctx *context.APIContext, users []*user_model.User) {
|
||||||
|
|
@ -279,3 +281,33 @@ func Unfollow(ctx *context.APIContext) {
|
||||||
}
|
}
|
||||||
ctx.Status(http.StatusNoContent)
|
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 {
|
} else {
|
||||||
ctx.Data["CardsNoneMsg"] = ctx.Tr("followers.outgoing.list.none", ctx.ContextUser.Name)
|
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":
|
case "activity":
|
||||||
// prepare heatmap data
|
// prepare heatmap data
|
||||||
if setting.Service.EnableUserHeatmap {
|
if setting.Service.EnableUserHeatmap {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"forgejo.org/models/forgefed"
|
"forgejo.org/models/forgefed"
|
||||||
"forgejo.org/models/user"
|
user_model "forgejo.org/models/user"
|
||||||
"forgejo.org/modules/activitypub"
|
"forgejo.org/modules/activitypub"
|
||||||
fm "forgejo.org/modules/forgefed"
|
fm "forgejo.org/modules/forgefed"
|
||||||
"forgejo.org/modules/log"
|
"forgejo.org/modules/log"
|
||||||
|
|
@ -47,32 +47,51 @@ func FindOrCreateFederationHost(ctx context.Context, actorURI string) (*forgefed
|
||||||
return federationHost, nil
|
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)
|
user, federatedUser, federationHost, err := findFederatedUser(ctx, actorURI)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
personID, err := fm.NewPersonID(actorURI, string(federationHost.NodeInfo.SoftwareName))
|
personID, err := fm.NewPersonID(actorURI, string(federationHost.NodeInfo.SoftwareName))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if user != nil {
|
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 {
|
} else {
|
||||||
log.Trace("Attempting to create new user and federatedUser for actorURI: %#v", actorURI)
|
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 {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
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)
|
log.Trace("Got user: %v", user.Name)
|
||||||
|
|
||||||
return user, federatedUser, federationHost, nil
|
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)
|
federationHost, err := FindOrCreateFederationHost(ctx, actorURI)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
|
|
@ -82,7 +101,7 @@ func findFederatedUser(ctx context.Context, actorURI string) (*user.User, *user.
|
||||||
return nil, nil, nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
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) {
|
func createFederationHostFromAP(ctx context.Context, actorID fm.ActorID) (*forgefed.FederationHost, error) {
|
||||||
actionsUser := user.NewAPServerActor()
|
actionsUser := user_model.NewAPServerActor()
|
||||||
|
|
||||||
clientFactory, err := activitypub.GetClientFactory(ctx)
|
clientFactory, err := activitypub.GetClientFactory(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -137,8 +156,8 @@ func createFederationHostFromAP(ctx context.Context, actorID fm.ActorID) (*forge
|
||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchUserFromAP(ctx context.Context, personID fm.PersonID, federationHostID int64) (*user.User, *user.FederatedUser, error) {
|
func fetchUserFromAP(ctx context.Context, personID fm.PersonID, federationHost *forgefed.FederationHost) (*user_model.User, *user_model.FederatedUser, error) {
|
||||||
actionsUser := user.NewAPServerActor()
|
actionsUser := user_model.NewAPServerActor()
|
||||||
clientFactory, err := activitypub.GetClientFactory(ctx)
|
clientFactory, err := activitypub.GetClientFactory(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
|
|
@ -164,16 +183,18 @@ func fetchUserFromAP(ctx context.Context, personID fm.PersonID, federationHostID
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("Fetched valid person from distant server: %q", person)
|
|
||||||
|
|
||||||
localFqdn, err := url.ParseRequestURI(setting.AppURL)
|
localFqdn, err := url.ParseRequestURI(setting.AppURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
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())
|
email := fmt.Sprintf("f%v@%v", uuid.New().String(), localFqdn.Hostname())
|
||||||
loginName := personID.AsLoginName()
|
loginName := personIDFromActor.AsLoginName()
|
||||||
name := fmt.Sprintf("@%v%v", person.PreferredUsername.String(), personID.HostSuffix())
|
name := fmt.Sprintf("@%v%v", person.PreferredUsername.String(), personIDFromActor.HostSuffix())
|
||||||
fullName := person.Name.String()
|
fullName := person.Name.String()
|
||||||
|
|
||||||
if len(person.Name) == 0 {
|
if len(person.Name) == 0 {
|
||||||
|
|
@ -190,7 +211,7 @@ func fetchUserFromAP(ctx context.Context, personID fm.PersonID, federationHostID
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
newUser := user.User{
|
newUser := user_model.User{
|
||||||
LowerName: strings.ToLower(name),
|
LowerName: strings.ToLower(name),
|
||||||
Name: name,
|
Name: name,
|
||||||
FullName: fullName,
|
FullName: fullName,
|
||||||
|
|
@ -201,15 +222,15 @@ func fetchUserFromAP(ctx context.Context, personID fm.PersonID, federationHostID
|
||||||
Salt: "",
|
Salt: "",
|
||||||
PasswdHashAlgo: "",
|
PasswdHashAlgo: "",
|
||||||
LoginName: loginName,
|
LoginName: loginName,
|
||||||
Type: user.UserTypeActivityPubUser,
|
Type: user_model.UserTypeActivityPubUser,
|
||||||
IsAdmin: false,
|
IsAdmin: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
federatedUser := user.FederatedUser{
|
federatedUser := user_model.FederatedUser{
|
||||||
ExternalID: personID.ID,
|
ExternalID: personIDFromActor.ID,
|
||||||
FederationHostID: federationHostID,
|
FederationHostID: federationHost.ID,
|
||||||
InboxPath: inbox.Path,
|
InboxPath: inbox.Path,
|
||||||
NormalizedOriginalURL: personID.AsURI(),
|
NormalizedOriginalURL: personIDFromActor.AsURI(),
|
||||||
KeyID: sql.NullString{
|
KeyID: sql.NullString{
|
||||||
String: person.PublicKey.ID.String(),
|
String: person.PublicKey.ID.String(),
|
||||||
Valid: true,
|
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
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
_, err = forgefed.GetFederationHost(ctx, federatedUser.FederationHostID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if federatedUser.PublicKey.Valid {
|
if federatedUser.PublicKey.Valid {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
<link rel="icon" href="{{AssetUrlPrefix}}/img/favicon.svg" type="image/svg+xml">
|
<link rel="icon" href="{{AssetUrlPrefix}}/img/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="alternate icon" href="{{AssetUrlPrefix}}/img/favicon.png" type="image/png">
|
<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 "base/head_script" .}}
|
||||||
{{template "shared/user/mention_highlight" .}}
|
{{template "shared/user/mention_highlight" .}}
|
||||||
{{template "base/head_opengraph" .}}
|
{{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": {
|
"/user/applications/oauth2": {
|
||||||
"get": {
|
"get": {
|
||||||
"produces": [
|
"produces": [
|
||||||
|
|
@ -22279,6 +22311,16 @@
|
||||||
},
|
},
|
||||||
"x-go-package": "forgejo.org/services/context"
|
"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": {
|
"AccessToken": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "AccessToken represents an API access token.",
|
"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"}}
|
{{svg "octicon-rss"}} {{ctx.Locale.Tr "user.activity"}}
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{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}}
|
{{if not .DisableStars}}
|
||||||
<a class="{{if eq .TabName "stars"}}active {{end}}item" href="{{.ContextUser.HomeLink}}?tab=stars">
|
<a class="{{if eq .TabName "stars"}}active {{end}}item" href="{{.ContextUser.HomeLink}}?tab=stars">
|
||||||
{{svg "octicon-star"}} {{ctx.Locale.Tr "user.starred"}}
|
{{svg "octicon-star"}} {{ctx.Locale.Tr "user.starred"}}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,14 @@
|
||||||
{{.ProfileReadme}}
|
{{.ProfileReadme}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</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}}
|
{{else}}
|
||||||
{{template "shared/repo_search" .}}
|
{{template "shared/repo_search" .}}
|
||||||
{{template "explore/repo_list" .}}
|
{{template "explore/repo_list" .}}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"forgejo.org/models/db"
|
||||||
"forgejo.org/models/unittest"
|
"forgejo.org/models/unittest"
|
||||||
user_model "forgejo.org/models/user"
|
user_model "forgejo.org/models/user"
|
||||||
"forgejo.org/modules/activitypub"
|
"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
|
// 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) {
|
func TestActivityPubPersonInboxFollow(t *testing.T) {
|
||||||
defer test.MockVariableValue(&setting.Federation.Enabled, true)()
|
defer test.MockVariableValue(&setting.Federation.Enabled, true)()
|
||||||
defer test.MockVariableValue(&setting.Federation.SignatureEnforced, false)()
|
|
||||||
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
|
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
|
||||||
|
|
||||||
federation.Init()
|
federation.Init()
|
||||||
|
|
@ -40,6 +40,7 @@ func TestActivityPubPersonInboxFollow(t *testing.T) {
|
||||||
|
|
||||||
distantURL := federatedSrv.URL
|
distantURL := federatedSrv.URL
|
||||||
distantUser15URL := fmt.Sprintf("%s/api/v1/activitypub/user-id/15", distantURL)
|
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})
|
localUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
||||||
localUser2URL := localUrl.JoinPath("/api/v1/activitypub/user-id/2").String()
|
localUser2URL := localUrl.JoinPath("/api/v1/activitypub/user-id/2").String()
|
||||||
|
|
@ -52,14 +53,16 @@ func TestActivityPubPersonInboxFollow(t *testing.T) {
|
||||||
`{"type":"Follow",`+
|
`{"type":"Follow",`+
|
||||||
`"actor":"%s",`+
|
`"actor":"%s",`+
|
||||||
`"object":"%s"}`,
|
`"object":"%s"}`,
|
||||||
distantUser15URL,
|
distantUser15AliasURL,
|
||||||
localUser2URL,
|
localUser2URL,
|
||||||
)
|
)
|
||||||
|
|
||||||
cf, err := activitypub.NewClientFactoryWithTimeout(60 * time.Second)
|
cf, err := activitypub.NewClientFactoryWithTimeout(60 * time.Second)
|
||||||
require.NoError(t, err)
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
resp, err := c.Post(followActivity, localUser2Inbox)
|
resp, err := c.Post(followActivity, localUser2Inbox)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
|
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
|
||||||
|
|
@ -94,9 +97,12 @@ func TestActivityPubPersonInboxFollow(t *testing.T) {
|
||||||
distantUser15URL,
|
distantUser15URL,
|
||||||
localUser2URL,
|
localUser2URL,
|
||||||
)
|
)
|
||||||
|
|
||||||
c, err = cf.WithKeysDirect(ctx, mock.ApActor.PrivKey,
|
c, err = cf.WithKeysDirect(ctx, mock.ApActor.PrivKey,
|
||||||
mock.ApActor.KeyID(federatedSrv.URL))
|
mock.ApActor.KeyID(federatedSrv.URL))
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
resp, err = c.Post(undoFollowActivity, localUser2Inbox)
|
resp, err = c.Post(undoFollowActivity, localUser2Inbox)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
|
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"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"forgejo.org/models/activities"
|
||||||
auth_model "forgejo.org/models/auth"
|
auth_model "forgejo.org/models/auth"
|
||||||
"forgejo.org/models/unittest"
|
"forgejo.org/models/unittest"
|
||||||
user_model "forgejo.org/models/user"
|
user_model "forgejo.org/models/user"
|
||||||
|
|
@ -26,9 +27,80 @@ import (
|
||||||
"github.com/stretchr/testify/require"
|
"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) {
|
func TestActivityPubPersonInboxNoteToDistant(t *testing.T) {
|
||||||
defer test.MockVariableValue(&setting.Federation.Enabled, true)()
|
defer test.MockVariableValue(&setting.Federation.Enabled, true)()
|
||||||
defer test.MockVariableValue(&setting.Federation.SignatureEnforced, false)()
|
|
||||||
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
|
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
|
||||||
|
|
||||||
federation.Init()
|
federation.Init()
|
||||||
|
|
@ -60,14 +132,17 @@ func TestActivityPubPersonInboxNoteToDistant(t *testing.T) {
|
||||||
distantUser15URL,
|
distantUser15URL,
|
||||||
localUser2URL,
|
localUser2URL,
|
||||||
)
|
)
|
||||||
|
|
||||||
ctx, _ := contexttest.MockAPIContext(t, localUser2Inbox)
|
ctx, _ := contexttest.MockAPIContext(t, localUser2Inbox)
|
||||||
cf, err := activitypub.NewClientFactoryWithTimeout(60 * time.Second)
|
cf, err := activitypub.NewClientFactoryWithTimeout(60 * time.Second)
|
||||||
require.NoError(t, err)
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
resp, err := c.Post(followActivity, localUser2Inbox)
|
resp, err := c.Post(followActivity, localUser2Inbox)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
|
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
|
||||||
|
|
||||||
// local action which triggers a user activity
|
// local action which triggers a user activity
|
||||||
|
|
@ -87,9 +162,11 @@ func TestActivityPubPersonInboxNoteToDistant(t *testing.T) {
|
||||||
// distant request activity & activity note
|
// distant request activity & activity note
|
||||||
localUser2ActivityNote := fmt.Sprintf("%v/activities/1", localUser2URL)
|
localUser2ActivityNote := fmt.Sprintf("%v/activities/1", localUser2URL)
|
||||||
localUser2Activity := fmt.Sprintf("%v/activities/1/activity", localUser2URL)
|
localUser2Activity := fmt.Sprintf("%v/activities/1/activity", localUser2URL)
|
||||||
|
|
||||||
resp, err = c.Get(localUser2ActivityNote)
|
resp, err = c.Get(localUser2ActivityNote)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
resp, err = c.Get(localUser2Activity)
|
resp, err = c.Get(localUser2Activity)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
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