From 8116214727879ca4316ae2cba9675d7170f021f1 Mon Sep 17 00:00:00 2001 From: Michael Jerger Date: Fri, 29 Dec 2023 15:48:45 +0100 Subject: [PATCH] introduce nodeinfo --- models/forgefed/actor.go | 204 +++++++++++------------ models/forgefed/actor_test.go | 24 +-- models/forgefed/nodeinfo.go | 93 ++++++++--- models/forgefed/nodeinfo_test.go | 30 +++- modules/validation/validateable.go | 2 +- routers/api/v1/activitypub/repository.go | 48 +++--- 6 files changed, 238 insertions(+), 163 deletions(-) diff --git a/models/forgefed/actor.go b/models/forgefed/actor.go index b633b61514..b03767f363 100644 --- a/models/forgefed/actor.go +++ b/models/forgefed/actor.go @@ -12,11 +12,7 @@ import ( "code.gitea.io/gitea/modules/validation" ) -type Validateables interface { - validation.Validateable - ActorID | PersonID | RepositoryID -} - +// ----------------------------- ActorID -------------------------------------------- type ActorID struct { ID string Source string @@ -27,31 +23,12 @@ type ActorID struct { UnvalidatedInput string } -type PersonID struct { - ActorID -} - -type RepositoryID struct { - ActorID -} - -// newActorID receives already validated inputs -func NewActorID(validatedURI *url.URL) (ActorID, error) { - pathWithActorID := strings.Split(validatedURI.Path, "/") - if containsEmptyString(pathWithActorID) { - pathWithActorID = removeEmptyStrings(pathWithActorID) +// Factory function for ActorID. Created struct is asserted to be valid +func NewActorID(uri string) (ActorID, error) { + result, err := newActorID(uri) + if err != nil { + return ActorID{}, err } - length := len(pathWithActorID) - pathWithoutActorID := strings.Join(pathWithActorID[0:length-1], "/") - id := pathWithActorID[length-1] - - result := ActorID{} - result.ID = id - result.Schema = validatedURI.Scheme - result.Host = validatedURI.Hostname() - result.Path = pathWithoutActorID - result.Port = validatedURI.Port() - result.UnvalidatedInput = validatedURI.String() if valid, outcome := validation.IsValid(result); !valid { return ActorID{}, outcome @@ -60,65 +37,6 @@ func NewActorID(validatedURI *url.URL) (ActorID, error) { return result, nil } -func newActorID(validatedURI *url.URL, source string) (ActorID, error) { - result, err := NewActorID(validatedURI) - if err != nil { - return ActorID{}, err - } - - result.Source = source - - return result, nil -} - -func NewPersonID(uri, source string) (PersonID, error) { - // TODO: remove after test - //if !validation.IsValidExternalURL(uri) { - // return PersonId{}, fmt.Errorf("uri %s is not a valid external url", uri) - //} - validatedURI, err := url.ParseRequestURI(uri) - if err != nil { - return PersonID{}, err - } - - actorID, err := newActorID(validatedURI, source) - if err != nil { - return PersonID{}, err - } - - // validate Person specific path - personID := PersonID{actorID} - if valid, outcome := validation.IsValid(personID); !valid { - return PersonID{}, outcome - } - - return personID, nil -} - -func NewRepositoryID(uri, source string) (RepositoryID, error) { - if !validation.IsAPIURL(uri) { - return RepositoryID{}, fmt.Errorf("uri %s is not a valid repo url on this host %s", uri, setting.AppURL+"api") - } - - validatedURI, err := url.ParseRequestURI(uri) - if err != nil { - return RepositoryID{}, err - } - - actorID, err := newActorID(validatedURI, source) - if err != nil { - return RepositoryID{}, err - } - - // validate Person specific path - repoID := RepositoryID{actorID} - if valid, outcome := validation.IsValid(repoID); !valid { - return RepositoryID{}, outcome - } - - return repoID, nil -} - func (id ActorID) AsURI() string { var result string if id.Port == "" { @@ -129,6 +47,47 @@ func (id ActorID) AsURI() string { return result } +func (id ActorID) Validate() []string { + var result []string + result = append(result, validation.ValidateNotEmpty(id.ID, "userId")...) + result = append(result, validation.ValidateNotEmpty(id.Schema, "schema")...) + result = append(result, validation.ValidateNotEmpty(id.Path, "path")...) + result = append(result, validation.ValidateNotEmpty(id.Host, "host")...) + result = append(result, validation.ValidateNotEmpty(id.UnvalidatedInput, "unvalidatedInput")...) + + if id.UnvalidatedInput != id.AsURI() { + result = append(result, fmt.Sprintf("not all input: %q was parsed: %q", id.UnvalidatedInput, id.AsURI())) + } + + return result +} + +// ----------------------------- PersonID -------------------------------------------- +type PersonID struct { + ActorID +} + +// Factory function for PersonID. Created struct is asserted to be valid +func NewPersonID(uri, source string) (PersonID, error) { + // TODO: remove after test + //if !validation.IsValidExternalURL(uri) { + // return PersonId{}, fmt.Errorf("uri %s is not a valid external url", uri) + //} + result, err := newActorID(uri) + if err != nil { + return PersonID{}, err + } + result.Source = source + + // validate Person specific path + personID := PersonID{result} + if valid, outcome := validation.IsValid(personID); !valid { + return PersonID{}, outcome + } + + return personID, nil +} + func (id PersonID) AsWebfinger() string { result := fmt.Sprintf("@%s@%s", strings.ToLower(id.ID), strings.ToLower(id.Host)) return result @@ -144,26 +103,10 @@ func (id PersonID) HostSuffix() string { return result } -// Validate collects error strings in a slice and returns this -func (id ActorID) Validate() []string { - var result []string - result = append(result, validation.ValidateNotEmpty(id.ID, "userId")...) - result = append(result, validation.ValidateNotEmpty(id.Schema, "schema")...) - result = append(result, validation.ValidateNotEmpty(id.Path, "path")...) - result = append(result, validation.ValidateNotEmpty(id.Host, "host")...) - result = append(result, validation.ValidateNotEmpty(id.UnvalidatedInput, "unvalidatedInput")...) - - if id.UnvalidatedInput != id.AsURI() { - result = append(result, fmt.Sprintf("not all input: %q was parsed: %q", id.UnvalidatedInput, id.AsURI())) - } - - return result -} - func (id PersonID) Validate() []string { result := id.ActorID.Validate() result = append(result, validation.ValidateNotEmpty(id.Source, "source")...) - result = append(result, validation.ValidateOneOf(id.Source, []string{"forgejo", "gitea"})...) + result = append(result, validation.ValidateOneOf(id.Source, []any{"forgejo", "gitea"})...) switch id.Source { case "forgejo", "gitea": if strings.ToLower(id.Path) != "api/v1/activitypub/user-id" && strings.ToLower(id.Path) != "api/activitypub/user-id" { @@ -173,10 +116,36 @@ func (id PersonID) Validate() []string { return result } +// ----------------------------- RepositoryID -------------------------------------------- + +type RepositoryID struct { + ActorID +} + +// Factory function for RepositoryID. Created struct is asserted to be valid. +func NewRepositoryID(uri, source string) (RepositoryID, error) { + if !validation.IsAPIURL(uri) { + return RepositoryID{}, fmt.Errorf("uri %s is not a valid repo url on this host %s", uri, setting.AppURL+"api") + } + result, err := newActorID(uri) + if err != nil { + return RepositoryID{}, err + } + result.Source = source + + // validate Person specific path + repoID := RepositoryID{result} + if valid, outcome := validation.IsValid(repoID); !valid { + return RepositoryID{}, outcome + } + + return repoID, nil +} + func (id RepositoryID) Validate() []string { result := id.ActorID.Validate() result = append(result, validation.ValidateNotEmpty(id.Source, "source")...) - result = append(result, validation.ValidateOneOf(id.Source, []string{"forgejo", "gitea"})...) + result = append(result, validation.ValidateOneOf(id.Source, []any{"forgejo", "gitea"})...) switch id.Source { case "forgejo", "gitea": if strings.ToLower(id.Path) != "api/v1/activitypub/repository-id" && strings.ToLower(id.Path) != "api/activitypub/repository-id" { @@ -204,3 +173,26 @@ func removeEmptyStrings(ls []string) []string { } return rs } + +func newActorID(uri string) (ActorID, error) { + validatedURI, err := url.ParseRequestURI(uri) + if err != nil { + return ActorID{}, err + } + pathWithActorID := strings.Split(validatedURI.Path, "/") + if containsEmptyString(pathWithActorID) { + pathWithActorID = removeEmptyStrings(pathWithActorID) + } + length := len(pathWithActorID) + pathWithoutActorID := strings.Join(pathWithActorID[0:length-1], "/") + id := pathWithActorID[length-1] + + result := ActorID{} + result.ID = id + result.Schema = validatedURI.Scheme + result.Host = validatedURI.Hostname() + result.Path = pathWithoutActorID + result.Port = validatedURI.Port() + result.UnvalidatedInput = validatedURI.String() + return result, nil +} diff --git a/models/forgefed/actor_test.go b/models/forgefed/actor_test.go index 10ff6a9d52..9e592e539d 100644 --- a/models/forgefed/actor_test.go +++ b/models/forgefed/actor_test.go @@ -65,18 +65,6 @@ func TestActorIdValidation(t *testing.T) { t.Errorf("validation error expected but was: %v\n", sut.Validate()) } - sut = ActorID{} - sut.ID = "1" - sut.Source = "forgejox" - sut.Schema = "https" - sut.Path = "api/v1/activitypub/user-id" - sut.Host = "an.other.host" - sut.Port = "" - sut.UnvalidatedInput = "https://an.other.host/api/v1/activitypub/user-id/1" - if sut.Validate()[0] != "Value forgejox is not contained in allowed values [[forgejo gitea]]" { - t.Errorf("validation error expected but was: %v\n", sut.Validate()) - } - sut = ActorID{} sut.ID = "1" sut.Source = "forgejo" @@ -102,6 +90,18 @@ func TestPersonIdValidation(t *testing.T) { if _, err := IsValid(sut); err.Error() != "path: \"path\" has to be a person specific api path" { t.Errorf("validation error expected but was: %v\n", err) } + + sut = PersonID{} + sut.ID = "1" + sut.Source = "forgejox" + sut.Schema = "https" + sut.Path = "api/v1/activitypub/user-id" + sut.Host = "an.other.host" + sut.Port = "" + sut.UnvalidatedInput = "https://an.other.host/api/v1/activitypub/user-id/1" + if sut.Validate()[0] != "Value forgejox is not contained in allowed values [[forgejo gitea]]" { + t.Errorf("validation error expected but was: %v\n", sut.Validate()) + } } func TestWebfingerId(t *testing.T) { diff --git a/models/forgefed/nodeinfo.go b/models/forgefed/nodeinfo.go index fb1e5d35da..0d721a9a24 100644 --- a/models/forgefed/nodeinfo.go +++ b/models/forgefed/nodeinfo.go @@ -4,6 +4,7 @@ package forgefed import ( + "fmt" "net/url" "code.gitea.io/gitea/modules/validation" @@ -14,22 +15,37 @@ type ( SourceType string ) -type SourceTypes []SourceType - const ( - ForgejoSourceType SourceType = "frogejo" + ForgejoSourceType SourceType = "forgejo" + GiteaSourceType SourceType = "gitea" ) -var KnownSourceTypes = SourceTypes{ - ForgejoSourceType, +var KnownSourceTypes = []any{ + ForgejoSourceType, GiteaSourceType, } +// ------------------------------------------------ NodeInfoWellKnown ------------------------------------------------ + // NodeInfo data type // swagger:model type NodeInfoWellKnown struct { Href string } +// Factory function for PersonID. Created struct is asserted to be valid +func NewNodeInfoWellKnown(body []byte) (NodeInfoWellKnown, error) { + result, err := NodeInfoWellKnownUnmarshalJSON(body) + if err != nil { + return NodeInfoWellKnown{}, err + } + + if valid, err := validation.IsValid(result); !valid { + return NodeInfoWellKnown{}, err + } + + return result, nil +} + func NodeInfoWellKnownUnmarshalJSON(data []byte) (NodeInfoWellKnown, error) { p := fastjson.Parser{} val, err := p.ParseBytes(data) @@ -40,19 +56,6 @@ func NodeInfoWellKnownUnmarshalJSON(data []byte) (NodeInfoWellKnown, error) { return NodeInfoWellKnown{Href: href}, nil } -func NewNodeInfoWellKnown(body []byte) (NodeInfoWellKnown, error) { - result, err := NodeInfoWellKnownUnmarshalJSON(body) - if err != nil { - return NodeInfoWellKnown{}, err - } - - if valid, outcome := validation.IsValid(result); !valid { - return NodeInfoWellKnown{}, outcome - } - - return NodeInfoWellKnown{}, nil -} - // Validate collects error strings in a slice and returns this func (node NodeInfoWellKnown) Validate() []string { var result []string @@ -68,7 +71,7 @@ func (node NodeInfoWellKnown) Validate() []string { result = append(result, "Href has to be absolute") } - result = append(result, validation.ValidateOneOf(parsedUrl.Scheme, []string{"http", "https"})...) + result = append(result, validation.ValidateOneOf(parsedUrl.Scheme, []any{"http", "https"})...) if parsedUrl.RawQuery != "" { result = append(result, "Href may not contain query") @@ -76,3 +79,55 @@ func (node NodeInfoWellKnown) Validate() []string { return result } + +func (id ActorID) AsWellKnownNodeInfoUri() string { + wellKnownPath := ".well-known/nodeinfo" + var result string + if id.Port == "" { + result = fmt.Sprintf("%s://%s/%s", id.Schema, id.Host, wellKnownPath) + } else { + result = fmt.Sprintf("%s://%s:%s/%s", id.Schema, id.Host, id.Port, wellKnownPath) + } + return result +} + +// ------------------------------------------------ NodeInfo ------------------------------------------------ + +// NodeInfo data type +// swagger:model +type NodeInfo struct { + Source SourceType +} + +func NodeInfoUnmarshalJSON(data []byte) (NodeInfo, error) { + p := fastjson.Parser{} + val, err := p.ParseBytes(data) + if err != nil { + return NodeInfo{}, err + } + source := string(val.GetStringBytes("software", "name")) + result := NodeInfo{} + result.Source = SourceType(source) + return result, nil +} + +func NewNodeInfo(body []byte) (NodeInfo, error) { + result, err := NodeInfoUnmarshalJSON(body) + if err != nil { + return NodeInfo{}, err + } + + if valid, err := validation.IsValid(result); !valid { + return NodeInfo{}, err + } + return result, nil +} + +// Validate collects error strings in a slice and returns this +func (node NodeInfo) Validate() []string { + var result []string + result = append(result, validation.ValidateNotEmpty(string(node.Source), "source")...) + result = append(result, validation.ValidateOneOf(node.Source, KnownSourceTypes)...) + + return result +} diff --git a/models/forgefed/nodeinfo_test.go b/models/forgefed/nodeinfo_test.go index 0df7b905b5..09e8f3fbae 100644 --- a/models/forgefed/nodeinfo_test.go +++ b/models/forgefed/nodeinfo_test.go @@ -29,10 +29,6 @@ func Test_NodeInfoWellKnownUnmarshalJSON(t *testing.T) { item: []byte(``), wantErr: fmt.Errorf("cannot parse JSON: cannot parse empty string; unparsed tail: \"\""), }, - // "with too long href": { - // item: []byte(`{"links":[{"href":"https://federated-repo.prod.meissa.de/api/v1/nodeinfohttps://federated-repo.prod.meissa.de/api/v1/nodeinfohttps://federated-repo.prod.meissa.de/api/v1/nodeinfohttps://federated-repo.prod.meissa.de/api/v1/nodeinfohttps://federated-repo.prod.meissa.de/api/v1/nodeinfohttps://federated-repo.prod.meissa.de/api/v1/nodeinfo","rel":"http://nodeinfo.diaspora.software/ns/schema/2.1"}]}`), - // wantErr: fmt.Errorf("cannot parse JSON: cannot parse empty string; unparsed tail: \"\""), - // }, } for name, tt := range tests { @@ -65,3 +61,29 @@ func Test_NodeInfoWellKnownValidate(t *testing.T) { t.Errorf("sut should be valid, %v, %v", sut, err) } } + +func Test_NewNodeInfoWellKnown(t *testing.T) { + sut, err := NewNodeInfoWellKnown([]byte(`{"links":[{"href":"https://federated-repo.prod.meissa.de/api/v1/nodeinfo","rel":"http://nodeinfo.diaspora.software/ns/schema/2.1"}]}`)) + expected := NodeInfoWellKnown{Href: "https://federated-repo.prod.meissa.de/api/v1/nodeinfo"} + if sut != expected { + t.Errorf("expected was: %v but was: %v", expected, sut) + } + + sut, err = NewNodeInfoWellKnown([]byte(`invalid`)) + if err == nil { + t.Errorf("error was expected here") + } +} + +func Test_NewNodeInfo(t *testing.T) { + sut, err := 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":{}}`)) + expected := NodeInfo{Source: "gitea"} + if sut != expected { + t.Errorf("expected was: %v but was: %v", expected, sut) + } + + sut, err = NewNodeInfo([]byte(`invalid`)) + if err == nil { + t.Errorf("error was expected here") + } +} diff --git a/modules/validation/validateable.go b/modules/validation/validateable.go index 312450e666..d1537a7a2c 100644 --- a/modules/validation/validateable.go +++ b/modules/validation/validateable.go @@ -28,7 +28,7 @@ func ValidateNotEmpty(value, fieldName string) []string { return []string{} } -func ValidateOneOf(value string, allowed []string) []string { +func ValidateOneOf(value any, allowed []any) []string { for _, allowedElem := range allowed { if value == allowedElem { return []string{} diff --git a/routers/api/v1/activitypub/repository.go b/routers/api/v1/activitypub/repository.go index 006f48d429..6638c2dd86 100644 --- a/routers/api/v1/activitypub/repository.go +++ b/routers/api/v1/activitypub/repository.go @@ -8,7 +8,6 @@ package activitypub // Then maybe save the node info in a DB table - this could be useful for validation import ( "fmt" - "io" "net/http" "strings" "time" @@ -23,7 +22,6 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" - "code.gitea.io/gitea/routers/utils" "github.com/google/uuid" ap "github.com/go-ap/activitypub" @@ -90,11 +88,12 @@ func RepositoryInbox(ctx *context.APIContext) { log.Info("RepositoryInbox: activity:%v", activity) // parse actorID (person) - // rawActorID, err := forgefed.NewActorID(activity.Actor.GetID().String()) + actorUri := activity.Actor.GetID().String() + rawActorID, err := forgefed.NewActorID(actorUri) + nodeInfo, err := createNodeInfo(ctx, rawActorID) + log.Info("RepositoryInbox: nodeInfo validated: %v", nodeInfo) - // nodeInfo, err := createNodeInfo(rawActorID) - - actorID, err := forgefed.NewPersonID(activity.Actor.GetID().String(), string(activity.Source)) + actorID, err := forgefed.NewPersonID(actorUri, string(activity.Source)) if err != nil { ctx.ServerError("Validate actorId", err) return @@ -184,6 +183,27 @@ func SearchUsersByLoginName(loginName string) ([]*user_model.User, error) { return users, nil } +func createNodeInfo(ctx *context.APIContext, actorID forgefed.ActorID) (forgefed.NodeInfo, error) { + actionsUser := user_model.NewActionsUser() + client, err := api.NewClient(ctx, actionsUser, "no idea where to get key material.") + if err != nil { + return forgefed.NodeInfo{}, err + } + body, err := client.GetBody(actorID.AsWellKnownNodeInfoUri()) + if err != nil { + return forgefed.NodeInfo{}, err + } + nodeInfoWellKnown, err := forgefed.NewNodeInfoWellKnown(body) + if err != nil { + return forgefed.NodeInfo{}, err + } + body, err = client.GetBody(nodeInfoWellKnown.Href) + if err != nil { + return forgefed.NodeInfo{}, err + } + return forgefed.NewNodeInfo(body) +} + // ToDo: Maybe use externalLoginUser func createUserFromAP(ctx *context.APIContext, personID forgefed.PersonID) (*user_model.User, error) { // ToDo: Do we get a publicKeyId from server, repo or owner or repo? @@ -193,24 +213,10 @@ func createUserFromAP(ctx *context.APIContext, personID forgefed.PersonID) (*use return &user_model.User{}, err } - response, err := client.Get(personID.AsURI()) + body, err := client.GetBody(personID.AsURI()) if err != nil { return &user_model.User{}, err } - log.Info("RepositoryInbox: got status: %v", response.Status) - - // validate response; ToDo: Should we widen the restrictions here? - if response.StatusCode != 200 { - err = fmt.Errorf("got non 200 status code for id: %v", personID.ID) - return &user_model.User{}, err - } - - defer response.Body.Close() - body, err := io.ReadAll(response.Body) - if err != nil { - return &user_model.User{}, err - } - log.Info("RepositoryInbox: got body: %v", utils.CharLimiter(string(body), 120)) person := ap.Person{} err = person.UnmarshalJSON(body)