loading up the forgejo repo on tangled to test page performance
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

next step on the way to federation

+1044 -3
+52
models/forgefed/federationhost.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package forgefed 5 + 6 + import ( 7 + "fmt" 8 + "strings" 9 + "time" 10 + 11 + "code.gitea.io/gitea/modules/timeutil" 12 + "code.gitea.io/gitea/modules/validation" 13 + ) 14 + 15 + // FederationHost data type 16 + // swagger:model 17 + type FederationHost struct { 18 + ID int64 `xorm:"pk autoincr"` 19 + HostFqdn string `xorm:"host_fqdn UNIQUE INDEX VARCHAR(255) NOT NULL"` 20 + NodeInfo NodeInfo `xorm:"extends NOT NULL"` 21 + LatestActivity time.Time `xorm:"NOT NULL"` 22 + Create timeutil.TimeStamp `xorm:"created"` 23 + Updated timeutil.TimeStamp `xorm:"updated"` 24 + } 25 + 26 + // Factory function for PersonID. Created struct is asserted to be valid 27 + func NewFederationHost(nodeInfo NodeInfo, hostFqdn string) (FederationHost, error) { 28 + result := FederationHost{ 29 + HostFqdn: strings.ToLower(hostFqdn), 30 + NodeInfo: nodeInfo, 31 + } 32 + if valid, err := validation.IsValid(result); !valid { 33 + return FederationHost{}, err 34 + } 35 + return result, nil 36 + } 37 + 38 + // Validate collects error strings in a slice and returns this 39 + func (host FederationHost) Validate() []string { 40 + var result []string 41 + result = append(result, validation.ValidateNotEmpty(host.HostFqdn, "HostFqdn")...) 42 + result = append(result, validation.ValidateMaxLen(host.HostFqdn, 255, "HostFqdn")...) 43 + result = append(result, host.NodeInfo.Validate()...) 44 + if host.HostFqdn != strings.ToLower(host.HostFqdn) { 45 + result = append(result, fmt.Sprintf("HostFqdn has to be lower case but was: %v", host.HostFqdn)) 46 + } 47 + if !host.LatestActivity.IsZero() && host.LatestActivity.After(time.Now().Add(10*time.Minute)) { 48 + result = append(result, fmt.Sprintf("Latest Activity may not be far futurer: %v", host.LatestActivity)) 49 + } 50 + 51 + return result 52 + }
+61
models/forgefed/federationhost_repository.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package forgefed 5 + 6 + import ( 7 + "context" 8 + "fmt" 9 + "strings" 10 + 11 + "code.gitea.io/gitea/models/db" 12 + "code.gitea.io/gitea/modules/validation" 13 + ) 14 + 15 + func init() { 16 + db.RegisterModel(new(FederationHost)) 17 + } 18 + 19 + func GetFederationHost(ctx context.Context, ID int64) (*FederationHost, error) { 20 + host := new(FederationHost) 21 + has, err := db.GetEngine(ctx).Where("id=?", ID).Get(host) 22 + if err != nil { 23 + return nil, err 24 + } else if !has { 25 + return nil, fmt.Errorf("FederationInfo record %v does not exist", ID) 26 + } 27 + if res, err := validation.IsValid(host); !res { 28 + return nil, fmt.Errorf("FederationInfo is not valid: %v", err) 29 + } 30 + return host, nil 31 + } 32 + 33 + func FindFederationHostByFqdn(ctx context.Context, fqdn string) (*FederationHost, error) { 34 + host := new(FederationHost) 35 + has, err := db.GetEngine(ctx).Where("host_fqdn=?", strings.ToLower(fqdn)).Get(host) 36 + if err != nil { 37 + return nil, err 38 + } else if !has { 39 + return nil, nil 40 + } 41 + if res, err := validation.IsValid(host); !res { 42 + return nil, fmt.Errorf("FederationInfo is not valid: %v", err) 43 + } 44 + return host, nil 45 + } 46 + 47 + func CreateFederationHost(ctx context.Context, host *FederationHost) error { 48 + if res, err := validation.IsValid(host); !res { 49 + return fmt.Errorf("FederationInfo is not valid: %v", err) 50 + } 51 + _, err := db.GetEngine(ctx).Insert(host) 52 + return err 53 + } 54 + 55 + func UpdateFederationHost(ctx context.Context, host *FederationHost) error { 56 + if res, err := validation.IsValid(host); !res { 57 + return fmt.Errorf("FederationInfo is not valid: %v", err) 58 + } 59 + _, err := db.GetEngine(ctx).ID(host.ID).Update(host) 60 + return err 61 + }
+55
models/forgefed/federationhost_test.go
··· 1 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package forgefed 5 + 6 + import ( 7 + "testing" 8 + "time" 9 + 10 + "code.gitea.io/gitea/modules/validation" 11 + ) 12 + 13 + func Test_FederationHostValidation(t *testing.T) { 14 + sut := FederationHost{ 15 + HostFqdn: "host.do.main", 16 + NodeInfo: NodeInfo{ 17 + SoftwareName: "forgejo", 18 + }, 19 + LatestActivity: time.Now(), 20 + } 21 + if res, err := validation.IsValid(sut); !res { 22 + t.Errorf("sut should be valid but was %q", err) 23 + } 24 + 25 + sut = FederationHost{ 26 + HostFqdn: "host.do.main", 27 + NodeInfo: NodeInfo{}, 28 + LatestActivity: time.Now(), 29 + } 30 + if res, _ := validation.IsValid(sut); res { 31 + t.Errorf("sut should be invalid") 32 + } 33 + 34 + sut = FederationHost{ 35 + HostFqdn: "host.do.main", 36 + NodeInfo: NodeInfo{ 37 + SoftwareName: "forgejo", 38 + }, 39 + LatestActivity: time.Now().Add(1 * time.Hour), 40 + } 41 + if res, _ := validation.IsValid(sut); res { 42 + t.Errorf("sut should be invalid: Future timestamp") 43 + } 44 + 45 + sut = FederationHost{ 46 + HostFqdn: "hOst.do.main", 47 + NodeInfo: NodeInfo{ 48 + SoftwareName: "forgejo", 49 + }, 50 + LatestActivity: time.Now(), 51 + } 52 + if res, _ := validation.IsValid(sut); res { 53 + t.Errorf("sut should be invalid: HostFqdn lower case") 54 + } 55 + }
+123
models/forgefed/nodeinfo.go
··· 1 + // Copyright 2023 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package forgefed 5 + 6 + import ( 7 + "net/url" 8 + 9 + "code.gitea.io/gitea/modules/validation" 10 + 11 + "github.com/valyala/fastjson" 12 + ) 13 + 14 + // ToDo: Search for full text SourceType and Source, also in .md files 15 + type ( 16 + SoftwareNameType string 17 + ) 18 + 19 + const ( 20 + ForgejoSourceType SoftwareNameType = "forgejo" 21 + GiteaSourceType SoftwareNameType = "gitea" 22 + ) 23 + 24 + var KnownSourceTypes = []any{ 25 + ForgejoSourceType, GiteaSourceType, 26 + } 27 + 28 + // ------------------------------------------------ NodeInfoWellKnown ------------------------------------------------ 29 + 30 + // NodeInfo data type 31 + // swagger:model 32 + type NodeInfoWellKnown struct { 33 + Href string 34 + } 35 + 36 + // Factory function for PersonID. Created struct is asserted to be valid 37 + func NewNodeInfoWellKnown(body []byte) (NodeInfoWellKnown, error) { 38 + result, err := NodeInfoWellKnownUnmarshalJSON(body) 39 + if err != nil { 40 + return NodeInfoWellKnown{}, err 41 + } 42 + 43 + if valid, err := validation.IsValid(result); !valid { 44 + return NodeInfoWellKnown{}, err 45 + } 46 + 47 + return result, nil 48 + } 49 + 50 + func NodeInfoWellKnownUnmarshalJSON(data []byte) (NodeInfoWellKnown, error) { 51 + p := fastjson.Parser{} 52 + val, err := p.ParseBytes(data) 53 + if err != nil { 54 + return NodeInfoWellKnown{}, err 55 + } 56 + href := string(val.GetStringBytes("links", "0", "href")) 57 + return NodeInfoWellKnown{Href: href}, nil 58 + } 59 + 60 + // Validate collects error strings in a slice and returns this 61 + func (node NodeInfoWellKnown) Validate() []string { 62 + var result []string 63 + result = append(result, validation.ValidateNotEmpty(node.Href, "Href")...) 64 + 65 + parsedURL, err := url.Parse(node.Href) 66 + if err != nil { 67 + result = append(result, err.Error()) 68 + return result 69 + } 70 + 71 + if parsedURL.Host == "" { 72 + result = append(result, "Href has to be absolute") 73 + } 74 + 75 + result = append(result, validation.ValidateOneOf(parsedURL.Scheme, []any{"http", "https"}, "parsedURL.Scheme")...) 76 + 77 + if parsedURL.RawQuery != "" { 78 + result = append(result, "Href may not contain query") 79 + } 80 + 81 + return result 82 + } 83 + 84 + // ------------------------------------------------ NodeInfo ------------------------------------------------ 85 + 86 + // NodeInfo data type 87 + // swagger:model 88 + type NodeInfo struct { 89 + SoftwareName SoftwareNameType 90 + } 91 + 92 + func NodeInfoUnmarshalJSON(data []byte) (NodeInfo, error) { 93 + p := fastjson.Parser{} 94 + val, err := p.ParseBytes(data) 95 + if err != nil { 96 + return NodeInfo{}, err 97 + } 98 + source := string(val.GetStringBytes("software", "name")) 99 + result := NodeInfo{} 100 + result.SoftwareName = SoftwareNameType(source) 101 + return result, nil 102 + } 103 + 104 + func NewNodeInfo(body []byte) (NodeInfo, error) { 105 + result, err := NodeInfoUnmarshalJSON(body) 106 + if err != nil { 107 + return NodeInfo{}, err 108 + } 109 + 110 + if valid, err := validation.IsValid(result); !valid { 111 + return NodeInfo{}, err 112 + } 113 + return result, nil 114 + } 115 + 116 + // Validate collects error strings in a slice and returns this 117 + func (node NodeInfo) Validate() []string { 118 + var result []string 119 + result = append(result, validation.ValidateNotEmpty(string(node.SoftwareName), "node.SoftwareName")...) 120 + result = append(result, validation.ValidateOneOf(node.SoftwareName, KnownSourceTypes, "node.SoftwareName")...) 121 + 122 + return result 123 + }
+89
models/forgefed/nodeinfo_test.go
··· 1 + // Copyright 2023 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package forgefed 5 + 6 + import ( 7 + "fmt" 8 + "reflect" 9 + "testing" 10 + 11 + "code.gitea.io/gitea/modules/validation" 12 + ) 13 + 14 + func Test_NodeInfoWellKnownUnmarshalJSON(t *testing.T) { 15 + type testPair struct { 16 + item []byte 17 + want NodeInfoWellKnown 18 + wantErr error 19 + } 20 + 21 + tests := map[string]testPair{ 22 + "with href": { 23 + item: []byte(`{"links":[{"href":"https://federated-repo.prod.meissa.de/api/v1/nodeinfo","rel":"http://nodeinfo.diaspora.software/ns/schema/2.1"}]}`), 24 + want: NodeInfoWellKnown{ 25 + Href: "https://federated-repo.prod.meissa.de/api/v1/nodeinfo", 26 + }, 27 + }, 28 + "empty": { 29 + item: []byte(``), 30 + wantErr: fmt.Errorf("cannot parse JSON: cannot parse empty string; unparsed tail: \"\""), 31 + }, 32 + } 33 + 34 + for name, tt := range tests { 35 + t.Run(name, func(t *testing.T) { 36 + got, err := NodeInfoWellKnownUnmarshalJSON(tt.item) 37 + if (err != nil || tt.wantErr != nil) && tt.wantErr.Error() != err.Error() { 38 + t.Errorf("UnmarshalJSON() error = \"%v\", wantErr \"%v\"", err, tt.wantErr) 39 + return 40 + } 41 + if !reflect.DeepEqual(got, tt.want) { 42 + t.Errorf("UnmarshalJSON() got = %q, want %q", got, tt.want) 43 + } 44 + }) 45 + } 46 + } 47 + 48 + func Test_NodeInfoWellKnownValidate(t *testing.T) { 49 + sut := NodeInfoWellKnown{Href: "https://federated-repo.prod.meissa.de/api/v1/nodeinfo"} 50 + if b, err := validation.IsValid(sut); !b { 51 + t.Errorf("sut should be valid, %v, %v", sut, err) 52 + } 53 + 54 + sut = NodeInfoWellKnown{Href: "./federated-repo.prod.meissa.de/api/v1/nodeinfo"} 55 + if _, err := validation.IsValid(sut); err.Error() != "Href has to be absolute\nValue is not contained in allowed values [http https]" { 56 + t.Errorf("validation error expected but was: %v\n", err) 57 + } 58 + 59 + sut = NodeInfoWellKnown{Href: "https://federated-repo.prod.meissa.de/api/v1/nodeinfo?alert=1"} 60 + if _, err := validation.IsValid(sut); err.Error() != "Href may not contain query" { 61 + t.Errorf("sut should be valid, %v, %v", sut, err) 62 + } 63 + } 64 + 65 + func Test_NewNodeInfoWellKnown(t *testing.T) { 66 + sut, _ := NewNodeInfoWellKnown([]byte(`{"links":[{"href":"https://federated-repo.prod.meissa.de/api/v1/nodeinfo","rel":"http://nodeinfo.diaspora.software/ns/schema/2.1"}]}`)) 67 + expected := NodeInfoWellKnown{Href: "https://federated-repo.prod.meissa.de/api/v1/nodeinfo"} 68 + if sut != expected { 69 + t.Errorf("expected was: %v but was: %v", expected, sut) 70 + } 71 + 72 + _, err := NewNodeInfoWellKnown([]byte(`invalid`)) 73 + if err == nil { 74 + t.Errorf("error was expected here") 75 + } 76 + } 77 + 78 + func Test_NewNodeInfo(t *testing.T) { 79 + sut, _ := NewNodeInfo([]byte(`{"version":"2.1","software":{"name":"gitea","version":"1.20.0+dev-2539-g5840cc6d3","repository":"https://github.com/go-gitea/gitea.git","homepage":"https://gitea.io/"},"protocols":["activitypub"],"services":{"inbound":[],"outbound":["rss2.0"]},"openRegistrations":true,"usage":{"users":{"total":13,"activeHalfyear":1,"activeMonth":1}},"metadata":{}}`)) 80 + expected := NodeInfo{SoftwareName: "gitea"} 81 + if sut != expected { 82 + t.Errorf("expected was: %v but was: %v", expected, sut) 83 + } 84 + 85 + _, err := NewNodeInfo([]byte(`invalid`)) 86 + if err == nil { 87 + t.Errorf("error was expected here") 88 + } 89 + }
+51 -3
modules/activitypub/client.go
··· 1 1 // Copyright 2022 The Gitea Authors. All rights reserved. 2 + // Copyright 2024 The Forgejo Authors. All rights reserved. 2 3 // SPDX-License-Identifier: MIT 3 4 5 + // TODO: Think about whether this should be moved to services/activitypub (compare to exosy/services/activitypub/client.go) 4 6 package activitypub 5 7 6 8 import ( ··· 10 12 "crypto/x509" 11 13 "encoding/pem" 12 14 "fmt" 15 + "io" 13 16 "net/http" 14 17 "strings" 15 18 "time" 16 19 17 20 user_model "code.gitea.io/gitea/models/user" 21 + "code.gitea.io/gitea/modules/log" 18 22 "code.gitea.io/gitea/modules/proxy" 19 23 "code.gitea.io/gitea/modules/setting" 20 24 ··· 84 88 Transport: &http.Transport{ 85 89 Proxy: proxy.Proxy(), 86 90 }, 91 + Timeout: 5 * time.Second, 87 92 }, 88 93 algs: setting.HttpsigAlgs, 89 94 digestAlg: httpsig.DigestAlgorithm(setting.Federation.DigestAlgorithm), ··· 96 101 } 97 102 98 103 // NewRequest function 99 - func (c *Client) NewRequest(b []byte, to string) (req *http.Request, err error) { 104 + func (c *Client) NewRequest(method string, b []byte, to string) (req *http.Request, err error) { 100 105 buf := bytes.NewBuffer(b) 101 - req, err = http.NewRequest(http.MethodPost, to, buf) 106 + req, err = http.NewRequest(method, to, buf) 102 107 if err != nil { 103 108 return nil, err 104 109 } ··· 116 121 // Post function 117 122 func (c *Client) Post(b []byte, to string) (resp *http.Response, err error) { 118 123 var req *http.Request 119 - if req, err = c.NewRequest(b, to); err != nil { 124 + if req, err = c.NewRequest(http.MethodPost, b, to); err != nil { 125 + return nil, err 126 + } 127 + resp, err = c.client.Do(req) 128 + return resp, err 129 + } 130 + 131 + // Create an http GET request with forgejo/gitea specific headers 132 + func (c *Client) Get(to string) (resp *http.Response, err error) { // ToDo: we might not need the b parameter 133 + var req *http.Request 134 + emptyBody := []byte{0} 135 + if req, err = c.NewRequest(http.MethodGet, emptyBody, to); err != nil { 120 136 return nil, err 121 137 } 122 138 resp, err = c.client.Do(req) 123 139 return resp, err 124 140 } 141 + 142 + // Create an http GET request with forgejo/gitea specific headers 143 + func (c *Client) GetBody(uri string) ([]byte, error) { 144 + response, err := c.Get(uri) 145 + if err != nil { 146 + return nil, err 147 + } 148 + log.Debug("Client: got status: %v", response.Status) 149 + if response.StatusCode != 200 { 150 + err = fmt.Errorf("got non 200 status code for id: %v", uri) 151 + return nil, err 152 + } 153 + defer response.Body.Close() 154 + body, err := io.ReadAll(response.Body) 155 + if err != nil { 156 + return nil, err 157 + } 158 + log.Debug("Client: got body: %v", charLimiter(string(body), 120)) 159 + return body, nil 160 + } 161 + 162 + // Limit number of characters in a string (useful to prevent log injection attacks and overly long log outputs) 163 + // Thanks to https://www.socketloop.com/tutorials/golang-characters-limiter-example 164 + func charLimiter(s string, limit int) string { 165 + reader := strings.NewReader(s) 166 + buff := make([]byte, limit) 167 + n, _ := io.ReadAtLeast(reader, buff, limit) 168 + if n != 0 { 169 + return fmt.Sprint(string(buff), "...") 170 + } 171 + return s 172 + }
+77
modules/activitypub/client_test.go
··· 1 1 // Copyright 2022 The Gitea Authors. All rights reserved. 2 + // Copyright 2023 The Forgejo Authors. All rights reserved. 2 3 // SPDX-License-Identifier: MIT 3 4 4 5 package activitypub ··· 14 15 "code.gitea.io/gitea/models/db" 15 16 "code.gitea.io/gitea/models/unittest" 16 17 user_model "code.gitea.io/gitea/models/user" 18 + "code.gitea.io/gitea/modules/log" 17 19 "code.gitea.io/gitea/modules/setting" 18 20 19 21 "github.com/stretchr/testify/assert" 22 + 23 + _ "github.com/mattn/go-sqlite3" 20 24 ) 25 + 26 + /* ToDo: Set Up tests for http get requests 27 + 28 + Set up an expected response for GET on api with user-id = 1: 29 + { 30 + "@context": [ 31 + "https://www.w3.org/ns/activitystreams", 32 + "https://w3id.org/security/v1" 33 + ], 34 + "id": "http://localhost:3000/api/v1/activitypub/user-id/1", 35 + "type": "Person", 36 + "icon": { 37 + "type": "Image", 38 + "mediaType": "image/png", 39 + "url": "http://localhost:3000/avatar/3120fd0edc57d5d41230013ad88232e2" 40 + }, 41 + "url": "http://localhost:3000/me", 42 + "inbox": "http://localhost:3000/api/v1/activitypub/user-id/1/inbox", 43 + "outbox": "http://localhost:3000/api/v1/activitypub/user-id/1/outbox", 44 + "preferredUsername": "me", 45 + "publicKey": { 46 + "id": "http://localhost:3000/api/v1/activitypub/user-id/1#main-key", 47 + "owner": "http://localhost:3000/api/v1/activitypub/user-id/1", 48 + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAo1VDZGWQBDTWKhpWiPQp\n7nD94UsKkcoFwDQVuxE3bMquKEHBomB4cwUnVou922YkL3AmSOr1sX2yJQGqnCLm\nOeKS74/mCIAoYlu0d75bqY4A7kE2VrQmQLZBbmpCTfrPqDaE6Mfm/kXaX7+hsrZS\n4bVvzZCYq8sjtRxdPk+9ku2QhvznwTRlWLvwHmFSGtlQYPRu+f/XqoVM/DVRA/Is\nwDk9yiNIecV+Isus0CBq1jGQkfuVNu1GK2IvcSg9MoDm3VH/tCayAP+xWm0g7sC8\nKay6Y/khvTvE7bWEKGQsJGvi3+4wITLVLVt+GoVOuCzdbhTV2CHBzn7h30AoZD0N\nY6eyb+Q142JykoHadcRwh1a36wgoG7E496wPvV3ST8xdiClca8cDNhOzCj8woY+t\nTFCMl32U3AJ4e/cAsxKRocYLZqc95dDqdNQiIyiRMMkf5NaA/QvelY4PmFuHC0WR\nVuJ4A3mcti2QLS9j0fSwSJdlfolgW6xaPgjdvuSQsgX1AgMBAAE=\n-----END PUBLIC KEY-----\n" 49 + } 50 + } 51 + 52 + Set up a user called "me" for all tests 53 + 54 + 55 + 56 + */ 57 + 58 + func TestNewClientReturnsClient(t *testing.T) { 59 + assert.NoError(t, unittest.PrepareTestDatabase()) 60 + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) 61 + pubID := "myGpgId" 62 + c, err := NewClient(db.DefaultContext, user, pubID) 63 + 64 + log.Debug("Client: %v\nError: %v", c, err) 65 + assert.NoError(t, err) 66 + } 67 + 68 + /* TODO: bring this test to work or delete 69 + func TestActivityPubSignedGet(t *testing.T) { 70 + assert.NoError(t, unittest.PrepareTestDatabase()) 71 + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1, Name: "me"}) 72 + pubID := "myGpgId" 73 + c, err := NewClient(db.DefaultContext, user, pubID) 74 + assert.NoError(t, err) 75 + 76 + expected := "TestActivityPubSignedGet" 77 + 78 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 79 + assert.Regexp(t, regexp.MustCompile("^"+setting.Federation.DigestAlgorithm), r.Header.Get("Digest")) 80 + assert.Contains(t, r.Header.Get("Signature"), pubID) 81 + assert.Equal(t, r.Header.Get("Content-Type"), ActivityStreamsContentType) 82 + body, err := io.ReadAll(r.Body) 83 + assert.NoError(t, err) 84 + assert.Equal(t, expected, string(body)) 85 + fmt.Fprint(w, expected) 86 + })) 87 + defer srv.Close() 88 + 89 + r, err := c.Get(srv.URL) 90 + assert.NoError(t, err) 91 + defer r.Body.Close() 92 + body, err := io.ReadAll(r.Body) 93 + assert.NoError(t, err) 94 + assert.Equal(t, expected, string(body)) 95 + 96 + } 97 + */ 21 98 22 99 func TestActivityPubSignedPost(t *testing.T) { 23 100 assert.NoError(t, unittest.PrepareTestDatabase())
+226
modules/forgefed/actor.go
··· 1 + // Copyright 2023, 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package forgefed 5 + 6 + import ( 7 + "fmt" 8 + "net/url" 9 + "strings" 10 + 11 + "code.gitea.io/gitea/modules/setting" 12 + "code.gitea.io/gitea/modules/validation" 13 + 14 + ap "github.com/go-ap/activitypub" 15 + ) 16 + 17 + // ----------------------------- ActorID -------------------------------------------- 18 + type ActorID struct { 19 + ID string 20 + Source string 21 + Schema string 22 + Path string 23 + Host string 24 + Port string 25 + UnvalidatedInput string 26 + } 27 + 28 + // Factory function for ActorID. Created struct is asserted to be valid 29 + func NewActorID(uri string) (ActorID, error) { 30 + result, err := newActorID(uri) 31 + if err != nil { 32 + return ActorID{}, err 33 + } 34 + 35 + if valid, outcome := validation.IsValid(result); !valid { 36 + return ActorID{}, outcome 37 + } 38 + 39 + return result, nil 40 + } 41 + 42 + func (id ActorID) AsURI() string { 43 + var result string 44 + if id.Port == "" { 45 + result = fmt.Sprintf("%s://%s/%s/%s", id.Schema, id.Host, id.Path, id.ID) 46 + } else { 47 + result = fmt.Sprintf("%s://%s:%s/%s/%s", id.Schema, id.Host, id.Port, id.Path, id.ID) 48 + } 49 + return result 50 + } 51 + 52 + func (id ActorID) Validate() []string { 53 + var result []string 54 + result = append(result, validation.ValidateNotEmpty(id.ID, "userId")...) 55 + result = append(result, validation.ValidateNotEmpty(id.Schema, "schema")...) 56 + result = append(result, validation.ValidateNotEmpty(id.Path, "path")...) 57 + result = append(result, validation.ValidateNotEmpty(id.Host, "host")...) 58 + result = append(result, validation.ValidateNotEmpty(id.UnvalidatedInput, "unvalidatedInput")...) 59 + 60 + if id.UnvalidatedInput != id.AsURI() { 61 + result = append(result, fmt.Sprintf("not all input was parsed, \nUnvalidated Input:%q \nParsed URI: %q", id.UnvalidatedInput, id.AsURI())) 62 + } 63 + 64 + return result 65 + } 66 + 67 + // ----------------------------- PersonID -------------------------------------------- 68 + type PersonID struct { 69 + ActorID 70 + } 71 + 72 + // Factory function for PersonID. Created struct is asserted to be valid 73 + func NewPersonID(uri, source string) (PersonID, error) { 74 + // TODO: remove after test 75 + //if !validation.IsValidExternalURL(uri) { 76 + // return PersonId{}, fmt.Errorf("uri %s is not a valid external url", uri) 77 + //} 78 + result, err := newActorID(uri) 79 + if err != nil { 80 + return PersonID{}, err 81 + } 82 + result.Source = source 83 + 84 + // validate Person specific path 85 + personID := PersonID{result} 86 + if valid, outcome := validation.IsValid(personID); !valid { 87 + return PersonID{}, outcome 88 + } 89 + 90 + return personID, nil 91 + } 92 + 93 + func (id PersonID) AsWebfinger() string { 94 + result := fmt.Sprintf("@%s@%s", strings.ToLower(id.ID), strings.ToLower(id.Host)) 95 + return result 96 + } 97 + 98 + func (id PersonID) AsLoginName() string { 99 + result := fmt.Sprintf("%s%s", strings.ToLower(id.ID), id.HostSuffix()) 100 + return result 101 + } 102 + 103 + func (id PersonID) HostSuffix() string { 104 + result := fmt.Sprintf("-%s", strings.ToLower(id.Host)) 105 + return result 106 + } 107 + 108 + func (id PersonID) Validate() []string { 109 + result := id.ActorID.Validate() 110 + result = append(result, validation.ValidateNotEmpty(id.Source, "source")...) 111 + result = append(result, validation.ValidateOneOf(id.Source, []any{"forgejo", "gitea"}, "Source")...) 112 + switch id.Source { 113 + case "forgejo", "gitea": 114 + if strings.ToLower(id.Path) != "api/v1/activitypub/user-id" && strings.ToLower(id.Path) != "api/activitypub/user-id" { 115 + result = append(result, fmt.Sprintf("path: %q has to be a person specific api path", id.Path)) 116 + } 117 + } 118 + return result 119 + } 120 + 121 + // ----------------------------- RepositoryID -------------------------------------------- 122 + 123 + type RepositoryID struct { 124 + ActorID 125 + } 126 + 127 + // Factory function for RepositoryID. Created struct is asserted to be valid. 128 + func NewRepositoryID(uri, source string) (RepositoryID, error) { 129 + if !validation.IsAPIURL(uri) { 130 + return RepositoryID{}, fmt.Errorf("uri %s is not a valid repo url on this host %s", uri, setting.AppURL+"api") 131 + } 132 + result, err := newActorID(uri) 133 + if err != nil { 134 + return RepositoryID{}, err 135 + } 136 + result.Source = source 137 + 138 + // validate Person specific path 139 + repoID := RepositoryID{result} 140 + if valid, outcome := validation.IsValid(repoID); !valid { 141 + return RepositoryID{}, outcome 142 + } 143 + 144 + return repoID, nil 145 + } 146 + 147 + func (id RepositoryID) Validate() []string { 148 + result := id.ActorID.Validate() 149 + result = append(result, validation.ValidateNotEmpty(id.Source, "source")...) 150 + result = append(result, validation.ValidateOneOf(id.Source, []any{"forgejo", "gitea"}, "Source")...) 151 + switch id.Source { 152 + case "forgejo", "gitea": 153 + if strings.ToLower(id.Path) != "api/v1/activitypub/repository-id" && strings.ToLower(id.Path) != "api/activitypub/repository-id" { 154 + result = append(result, fmt.Sprintf("path: %q has to be a repo specific api path", id.Path)) 155 + } 156 + } 157 + return result 158 + } 159 + 160 + func containsEmptyString(ar []string) bool { 161 + for _, elem := range ar { 162 + if elem == "" { 163 + return true 164 + } 165 + } 166 + return false 167 + } 168 + 169 + func removeEmptyStrings(ls []string) []string { 170 + var rs []string 171 + for _, str := range ls { 172 + if str != "" { 173 + rs = append(rs, str) 174 + } 175 + } 176 + return rs 177 + } 178 + 179 + func newActorID(uri string) (ActorID, error) { 180 + validatedURI, err := url.ParseRequestURI(uri) 181 + if err != nil { 182 + return ActorID{}, err 183 + } 184 + pathWithActorID := strings.Split(validatedURI.Path, "/") 185 + if containsEmptyString(pathWithActorID) { 186 + pathWithActorID = removeEmptyStrings(pathWithActorID) 187 + } 188 + length := len(pathWithActorID) 189 + pathWithoutActorID := strings.Join(pathWithActorID[0:length-1], "/") 190 + id := pathWithActorID[length-1] 191 + 192 + result := ActorID{} 193 + result.ID = id 194 + result.Schema = validatedURI.Scheme 195 + result.Host = validatedURI.Hostname() 196 + result.Path = pathWithoutActorID 197 + result.Port = validatedURI.Port() 198 + result.UnvalidatedInput = uri 199 + return result, nil 200 + } 201 + 202 + // ----------------------------- ForgePerson ------------------------------------- 203 + 204 + // ForgePerson activity data type 205 + // swagger:model 206 + type ForgePerson struct { 207 + // swagger:ignore 208 + ap.Actor 209 + } 210 + 211 + func (s ForgePerson) MarshalJSON() ([]byte, error) { 212 + return s.Actor.MarshalJSON() 213 + } 214 + 215 + func (s *ForgePerson) UnmarshalJSON(data []byte) error { 216 + return s.Actor.UnmarshalJSON(data) 217 + } 218 + 219 + func (s ForgePerson) Validate() []string { 220 + var result []string 221 + result = append(result, validation.ValidateNotEmpty(string(s.Type), "Type")...) 222 + result = append(result, validation.ValidateOneOf(string(s.Type), []any{string(ap.PersonType)}, "Type")...) 223 + result = append(result, validation.ValidateNotEmpty(s.PreferredUsername.String(), "PreferredUsername")...) 224 + 225 + return result 226 + }
+223
modules/forgefed/actor_test.go
··· 1 + // Copyright 2023, 2024 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package forgefed 5 + 6 + import ( 7 + "reflect" 8 + "strings" 9 + "testing" 10 + 11 + "code.gitea.io/gitea/modules/setting" 12 + "code.gitea.io/gitea/modules/validation" 13 + 14 + ap "github.com/go-ap/activitypub" 15 + ) 16 + 17 + func TestNewPersonId(t *testing.T) { 18 + expected := PersonID{} 19 + expected.ID = "1" 20 + expected.Source = "forgejo" 21 + expected.Schema = "https" 22 + expected.Path = "api/v1/activitypub/user-id" 23 + expected.Host = "an.other.host" 24 + expected.Port = "" 25 + expected.UnvalidatedInput = "https://an.other.host/api/v1/activitypub/user-id/1" 26 + sut, _ := NewPersonID("https://an.other.host/api/v1/activitypub/user-id/1", "forgejo") 27 + if sut != expected { 28 + t.Errorf("expected: %v\n but was: %v\n", expected, sut) 29 + } 30 + 31 + expected = PersonID{} 32 + expected.ID = "1" 33 + expected.Source = "forgejo" 34 + expected.Schema = "https" 35 + expected.Path = "api/v1/activitypub/user-id" 36 + expected.Host = "an.other.host" 37 + expected.Port = "443" 38 + expected.UnvalidatedInput = "https://an.other.host:443/api/v1/activitypub/user-id/1" 39 + sut, _ = NewPersonID("https://an.other.host:443/api/v1/activitypub/user-id/1", "forgejo") 40 + if sut != expected { 41 + t.Errorf("expected: %v\n but was: %v\n", expected, sut) 42 + } 43 + } 44 + 45 + func TestNewRepositoryId(t *testing.T) { 46 + setting.AppURL = "http://localhost:3000/" 47 + expected := RepositoryID{} 48 + expected.ID = "1" 49 + expected.Source = "forgejo" 50 + expected.Schema = "http" 51 + expected.Path = "api/activitypub/repository-id" 52 + expected.Host = "localhost" 53 + expected.Port = "3000" 54 + expected.UnvalidatedInput = "http://localhost:3000/api/activitypub/repository-id/1" 55 + sut, _ := NewRepositoryID("http://localhost:3000/api/activitypub/repository-id/1", "forgejo") 56 + if sut != expected { 57 + t.Errorf("expected: %v\n but was: %v\n", expected, sut) 58 + } 59 + } 60 + 61 + func TestActorIdValidation(t *testing.T) { 62 + sut := ActorID{} 63 + sut.Source = "forgejo" 64 + sut.Schema = "https" 65 + sut.Path = "api/v1/activitypub/user-id" 66 + sut.Host = "an.other.host" 67 + sut.Port = "" 68 + sut.UnvalidatedInput = "https://an.other.host/api/v1/activitypub/user-id/" 69 + if sut.Validate()[0] != "userId should not be empty" { 70 + t.Errorf("validation error expected but was: %v\n", sut.Validate()) 71 + } 72 + 73 + sut = ActorID{} 74 + sut.ID = "1" 75 + sut.Source = "forgejo" 76 + sut.Schema = "https" 77 + sut.Path = "api/v1/activitypub/user-id" 78 + sut.Host = "an.other.host" 79 + sut.Port = "" 80 + sut.UnvalidatedInput = "https://an.other.host/api/v1/activitypub/user-id/1?illegal=action" 81 + if sut.Validate()[0] != "not all input was parsed, \nUnvalidated Input:\"https://an.other.host/api/v1/activitypub/user-id/1?illegal=action\" \nParsed URI: \"https://an.other.host/api/v1/activitypub/user-id/1\"" { 82 + t.Errorf("validation error expected but was: %v\n", sut.Validate()[0]) 83 + } 84 + } 85 + 86 + func TestPersonIdValidation(t *testing.T) { 87 + sut := PersonID{} 88 + sut.ID = "1" 89 + sut.Source = "forgejo" 90 + sut.Schema = "https" 91 + sut.Path = "path" 92 + sut.Host = "an.other.host" 93 + sut.Port = "" 94 + sut.UnvalidatedInput = "https://an.other.host/path/1" 95 + if _, err := validation.IsValid(sut); err.Error() != "path: \"path\" has to be a person specific api path" { 96 + t.Errorf("validation error expected but was: %v\n", err) 97 + } 98 + 99 + sut = PersonID{} 100 + sut.ID = "1" 101 + sut.Source = "forgejox" 102 + sut.Schema = "https" 103 + sut.Path = "api/v1/activitypub/user-id" 104 + sut.Host = "an.other.host" 105 + sut.Port = "" 106 + sut.UnvalidatedInput = "https://an.other.host/api/v1/activitypub/user-id/1" 107 + if sut.Validate()[0] != "Value forgejox is not contained in allowed values [forgejo gitea]" { 108 + t.Errorf("validation error expected but was: %v\n", sut.Validate()[0]) 109 + } 110 + } 111 + 112 + func TestWebfingerId(t *testing.T) { 113 + sut, _ := NewPersonID("https://codeberg.org/api/v1/activitypub/user-id/12345", "forgejo") 114 + if sut.AsWebfinger() != "@12345@codeberg.org" { 115 + t.Errorf("wrong webfinger: %v", sut.AsWebfinger()) 116 + } 117 + 118 + sut, _ = NewPersonID("https://Codeberg.org/api/v1/activitypub/user-id/12345", "forgejo") 119 + if sut.AsWebfinger() != "@12345@codeberg.org" { 120 + t.Errorf("wrong webfinger: %v", sut.AsWebfinger()) 121 + } 122 + } 123 + 124 + func TestShouldThrowErrorOnInvalidInput(t *testing.T) { 125 + var err any 126 + // TODO: remove after test 127 + //_, err = NewPersonId("", "forgejo") 128 + //if err == nil { 129 + // t.Errorf("empty input should be invalid.") 130 + //} 131 + 132 + _, err = NewPersonID("http://localhost:3000/api/v1/something", "forgejo") 133 + if err == nil { 134 + t.Errorf("localhost uris are not external") 135 + } 136 + _, err = NewPersonID("./api/v1/something", "forgejo") 137 + if err == nil { 138 + t.Errorf("relative uris are not allowed") 139 + } 140 + _, err = NewPersonID("http://1.2.3.4/api/v1/something", "forgejo") 141 + if err == nil { 142 + t.Errorf("uri may not be ip-4 based") 143 + } 144 + _, err = NewPersonID("http:///[fe80::1ff:fe23:4567:890a%25eth0]/api/v1/something", "forgejo") 145 + if err == nil { 146 + t.Errorf("uri may not be ip-6 based") 147 + } 148 + _, err = NewPersonID("https://codeberg.org/api/v1/activitypub/../activitypub/user-id/12345", "forgejo") 149 + if err == nil { 150 + t.Errorf("uri may not contain relative path elements") 151 + } 152 + _, err = NewPersonID("https://myuser@an.other.host/api/v1/activitypub/user-id/1", "forgejo") 153 + if err == nil { 154 + t.Errorf("uri may not contain unparsed elements") 155 + } 156 + 157 + _, err = NewPersonID("https://an.other.host/api/v1/activitypub/user-id/1", "forgejo") 158 + if err != nil { 159 + t.Errorf("this uri should be valid but was: %v", err) 160 + } 161 + } 162 + 163 + func Test_PersonMarshalJSON(t *testing.T) { 164 + sut := ForgePerson{} 165 + sut.Type = "Person" 166 + sut.PreferredUsername = ap.NaturalLanguageValuesNew() 167 + sut.PreferredUsername.Set("en", ap.Content("MaxMuster")) 168 + result, _ := sut.MarshalJSON() 169 + if string(result) != "{\"type\":\"Person\",\"preferredUsername\":\"MaxMuster\"}" { 170 + t.Errorf("MarshalJSON() was = %q", result) 171 + } 172 + } 173 + 174 + func Test_PersonUnmarshalJSON(t *testing.T) { 175 + expected := &ForgePerson{ 176 + Actor: ap.Actor{ 177 + Type: "Person", 178 + PreferredUsername: ap.NaturalLanguageValues{ 179 + ap.LangRefValue{Ref: "en", Value: []byte("MaxMuster")}, 180 + }, 181 + }, 182 + } 183 + sut := new(ForgePerson) 184 + err := sut.UnmarshalJSON([]byte(`{"type":"Person","preferredUsername":"MaxMuster"}`)) 185 + if err != nil { 186 + t.Errorf("UnmarshalJSON() unexpected error: %v", err) 187 + } 188 + x, _ := expected.MarshalJSON() 189 + y, _ := sut.MarshalJSON() 190 + if !reflect.DeepEqual(x, y) { 191 + t.Errorf("UnmarshalJSON() expected: %q got: %q", x, y) 192 + } 193 + 194 + expectedStr := strings.ReplaceAll(strings.ReplaceAll(`{ 195 + "id":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/10", 196 + "type":"Person", 197 + "icon":{"type":"Image","mediaType":"image/png","url":"https://federated-repo.prod.meissa.de/avatar/fa7f9c4af2a64f41b1bef292bf872614"}, 198 + "url":"https://federated-repo.prod.meissa.de/stargoose9", 199 + "inbox":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/10/inbox", 200 + "outbox":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/10/outbox", 201 + "preferredUsername":"stargoose9", 202 + "publicKey":{"id":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/10#main-key", 203 + "owner":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/10", 204 + "publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBoj...XAgMBAAE=\n-----END PUBLIC KEY-----\n"}}`, 205 + "\n", ""), 206 + "\t", "") 207 + err = sut.UnmarshalJSON([]byte(expectedStr)) 208 + if err != nil { 209 + t.Errorf("UnmarshalJSON() unexpected error: %v", err) 210 + } 211 + result, _ := sut.MarshalJSON() 212 + if expectedStr != string(result) { 213 + t.Errorf("UnmarshalJSON() expected: %q got: %q", expectedStr, result) 214 + } 215 + } 216 + 217 + func TestForgePersonValidation(t *testing.T) { 218 + sut := new(ForgePerson) 219 + sut.UnmarshalJSON([]byte(`{"type":"Person","preferredUsername":"MaxMuster"}`)) 220 + if res, _ := validation.IsValid(sut); !res { 221 + t.Errorf("sut expected to be valid: %v\n", sut.Validate()) 222 + } 223 + }
+19
modules/forgefed/nodeinfo.go
··· 1 + // Copyright 2023 The Forgejo Authors. All rights reserved. 2 + // SPDX-License-Identifier: MIT 3 + 4 + package forgefed 5 + 6 + import ( 7 + "fmt" 8 + ) 9 + 10 + func (id ActorID) AsWellKnownNodeInfoURI() string { 11 + wellKnownPath := ".well-known/nodeinfo" 12 + var result string 13 + if id.Port == "" { 14 + result = fmt.Sprintf("%s://%s/%s", id.Schema, id.Host, wellKnownPath) 15 + } else { 16 + result = fmt.Sprintf("%s://%s:%s/%s", id.Schema, id.Host, id.Port, wellKnownPath) 17 + } 18 + return result 19 + }
+68
services/federation/federation_service.go
··· 5 5 6 6 import ( 7 7 "context" 8 + "fmt" 8 9 "net/http" 9 10 11 + "code.gitea.io/gitea/models/forgefed" 12 + "code.gitea.io/gitea/models/user" 13 + "code.gitea.io/gitea/modules/activitypub" 10 14 fm "code.gitea.io/gitea/modules/forgefed" 11 15 "code.gitea.io/gitea/modules/log" 12 16 "code.gitea.io/gitea/modules/validation" ··· 26 30 } 27 31 log.Info("Activity validated:%v", activity) 28 32 33 + // parse actorID (person) 34 + actorURI := activity.Actor.GetID().String() 35 + log.Info("actorURI was: %v", actorURI) 36 + federationHost, err := GetFederationHostForURI(ctx, actorURI) 37 + if err != nil { 38 + return http.StatusInternalServerError, "Wrong FederationHost", err 39 + } 40 + if !activity.IsNewer(federationHost.LatestActivity) { 41 + return http.StatusNotAcceptable, "Activity out of order.", fmt.Errorf("Activity already processed") 42 + } 43 + 29 44 return 0, "", nil 30 45 } 46 + 47 + func CreateFederationHostFromAP(ctx context.Context, actorID fm.ActorID) (*forgefed.FederationHost, error) { 48 + actionsUser := user.NewActionsUser() 49 + client, err := activitypub.NewClient(ctx, actionsUser, "no idea where to get key material.") 50 + if err != nil { 51 + return nil, err 52 + } 53 + body, err := client.GetBody(actorID.AsWellKnownNodeInfoURI()) 54 + if err != nil { 55 + return nil, err 56 + } 57 + nodeInfoWellKnown, err := forgefed.NewNodeInfoWellKnown(body) 58 + if err != nil { 59 + return nil, err 60 + } 61 + body, err = client.GetBody(nodeInfoWellKnown.Href) 62 + if err != nil { 63 + return nil, err 64 + } 65 + nodeInfo, err := forgefed.NewNodeInfo(body) 66 + if err != nil { 67 + return nil, err 68 + } 69 + result, err := forgefed.NewFederationHost(nodeInfo, actorID.Host) 70 + if err != nil { 71 + return nil, err 72 + } 73 + err = forgefed.CreateFederationHost(ctx, &result) 74 + if err != nil { 75 + return nil, err 76 + } 77 + return &result, nil 78 + } 79 + 80 + func GetFederationHostForURI(ctx context.Context, actorURI string) (*forgefed.FederationHost, error) { 81 + log.Info("Input was: %v", actorURI) 82 + rawActorID, err := fm.NewActorID(actorURI) 83 + if err != nil { 84 + return nil, err 85 + } 86 + federationHost, err := forgefed.FindFederationHostByFqdn(ctx, rawActorID.Host) 87 + if err != nil { 88 + return nil, err 89 + } 90 + if federationHost == nil { 91 + result, err := CreateFederationHostFromAP(ctx, rawActorID) 92 + if err != nil { 93 + return nil, err 94 + } 95 + federationHost = result 96 + } 97 + return federationHost, nil 98 + }