introduce nodeinfo

This commit is contained in:
Michael Jerger 2023-12-29 15:48:45 +01:00
parent 587bd07372
commit 8116214727
6 changed files with 238 additions and 163 deletions

View file

@ -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,32 +23,13 @@ type ActorID struct {
UnvalidatedInput string
}
type PersonID struct {
ActorID
// 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
}
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)
}
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
}

View file

@ -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) {

View file

@ -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
}

View file

@ -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")
}
}

View file

@ -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{}

View file

@ -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)