diff --git a/models/forgefed/federationhost.go b/models/forgefed/federationhost.go index 7db49e58e8..29f1b7d28e 100644 --- a/models/forgefed/federationhost.go +++ b/models/forgefed/federationhost.go @@ -1,4 +1,4 @@ -// Copyright 2024 The Forgejo Authors. All rights reserved. +// Copyright 2024, 2025 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT package forgefed @@ -19,18 +19,22 @@ type FederationHost struct { ID int64 `xorm:"pk autoincr"` HostFqdn string `xorm:"host_fqdn UNIQUE INDEX VARCHAR(255) NOT NULL"` NodeInfo NodeInfo `xorm:"extends NOT NULL"` + HostPort uint16 `xorm:"NOT NULL DEFAULT 443"` + HostSchema string `xorm:"NOT NULL DEFAULT 'https'"` LatestActivity time.Time `xorm:"NOT NULL"` - Created timeutil.TimeStamp `xorm:"created"` - Updated timeutil.TimeStamp `xorm:"updated"` KeyID sql.NullString `xorm:"key_id UNIQUE"` PublicKey sql.Null[sql.RawBytes] `xorm:"BLOB"` + Created timeutil.TimeStamp `xorm:"created"` + Updated timeutil.TimeStamp `xorm:"updated"` } // Factory function for FederationHost. Created struct is asserted to be valid. -func NewFederationHost(nodeInfo NodeInfo, hostFqdn string) (FederationHost, error) { +func NewFederationHost(hostFqdn string, nodeInfo NodeInfo, port uint16, schema string) (FederationHost, error) { result := FederationHost{ - HostFqdn: strings.ToLower(hostFqdn), - NodeInfo: nodeInfo, + HostFqdn: strings.ToLower(hostFqdn), + NodeInfo: nodeInfo, + HostPort: port, + HostSchema: schema, } if valid, err := validation.IsValid(result); !valid { return FederationHost{}, err @@ -43,6 +47,8 @@ func (host FederationHost) Validate() []string { var result []string result = append(result, validation.ValidateNotEmpty(host.HostFqdn, "HostFqdn")...) result = append(result, validation.ValidateMaxLen(host.HostFqdn, 255, "HostFqdn")...) + result = append(result, validation.ValidateNotEmpty(host.HostPort, "HostPort")...) + result = append(result, validation.ValidateNotEmpty(host.HostSchema, "HostSchema")...) result = append(result, host.NodeInfo.Validate()...) if host.HostFqdn != strings.ToLower(host.HostFqdn) { result = append(result, fmt.Sprintf("HostFqdn has to be lower case but was: %v", host.HostFqdn)) diff --git a/models/forgefed/federationhost_repository.go b/models/forgefed/federationhost_repository.go index fa1f906824..687966605f 100644 --- a/models/forgefed/federationhost_repository.go +++ b/models/forgefed/federationhost_repository.go @@ -6,7 +6,6 @@ package forgefed import ( "context" "fmt" - "strings" "forgejo.org/models/db" "forgejo.org/modules/validation" @@ -44,8 +43,18 @@ func findFederationHostFromDB(ctx context.Context, searchKey, searchValue string return host, nil } -func FindFederationHostByFqdn(ctx context.Context, fqdn string) (*FederationHost, error) { - return findFederationHostFromDB(ctx, "host_fqdn=?", strings.ToLower(fqdn)) +func FindFederationHostByFqdnAndPort(ctx context.Context, fqdn string, port uint16) (*FederationHost, error) { + host := new(FederationHost) + has, err := db.GetEngine(ctx).Where("host_fqdn=? AND host_port=?", fqdn, port).Get(host) + if err != nil { + return nil, err + } else if !has { + return nil, nil + } + if res, err := validation.IsValid(host); !res { + return nil, err + } + return host, nil } func FindFederationHostByKeyID(ctx context.Context, keyID string) (*FederationHost, error) { diff --git a/models/forgefed/federationhost_test.go b/models/forgefed/federationhost_test.go index 7e48a41d3b..824495c9cb 100644 --- a/models/forgefed/federationhost_test.go +++ b/models/forgefed/federationhost_test.go @@ -18,6 +18,8 @@ func Test_FederationHostValidation(t *testing.T) { SoftwareName: "forgejo", }, LatestActivity: time.Now(), + HostPort: 443, + HostSchema: "https", } if res, err := validation.IsValid(sut); !res { t.Errorf("sut should be valid but was %q", err) @@ -29,6 +31,8 @@ func Test_FederationHostValidation(t *testing.T) { SoftwareName: "forgejo", }, LatestActivity: time.Now(), + HostPort: 443, + HostSchema: "https", } if res, _ := validation.IsValid(sut); res { t.Errorf("sut should be invalid: HostFqdn empty") @@ -40,6 +44,8 @@ func Test_FederationHostValidation(t *testing.T) { SoftwareName: "forgejo", }, LatestActivity: time.Now(), + HostPort: 443, + HostSchema: "https", } if res, _ := validation.IsValid(sut); res { t.Errorf("sut should be invalid: HostFqdn too long (len=256)") @@ -49,6 +55,8 @@ func Test_FederationHostValidation(t *testing.T) { HostFqdn: "host.do.main", NodeInfo: NodeInfo{}, LatestActivity: time.Now(), + HostPort: 443, + HostSchema: "https", } if res, _ := validation.IsValid(sut); res { t.Errorf("sut should be invalid: NodeInfo invalid") @@ -60,6 +68,8 @@ func Test_FederationHostValidation(t *testing.T) { SoftwareName: "forgejo", }, LatestActivity: time.Now().Add(1 * time.Hour), + HostPort: 443, + HostSchema: "https", } if res, _ := validation.IsValid(sut); res { t.Errorf("sut should be invalid: Future timestamp") @@ -71,6 +81,8 @@ func Test_FederationHostValidation(t *testing.T) { SoftwareName: "forgejo", }, LatestActivity: time.Now(), + HostPort: 443, + HostSchema: "https", } if res, _ := validation.IsValid(sut); res { t.Errorf("sut should be invalid: HostFqdn lower case") diff --git a/models/forgejo_migrations/migrate.go b/models/forgejo_migrations/migrate.go index ef446add4e..73226c525f 100644 --- a/models/forgejo_migrations/migrate.go +++ b/models/forgejo_migrations/migrate.go @@ -96,6 +96,8 @@ var migrations = []*Migration{ NewMigration("Add pronoun privacy settings to user", AddHidePronounsOptionToUser), // v28 -> v29 NewMigration("Add public key information to `FederatedUser` and `FederationHost`", AddPublicKeyInformationForFederation), + // v29 -> v30 + NewMigration("Migrate `User.NormalizedFederatedURI` column to extract port & schema into FederatedHost", MigrateNormalizedFederatedURI), } // GetCurrentDBVersion returns the current Forgejo database version. diff --git a/models/forgejo_migrations/v30.go b/models/forgejo_migrations/v30.go new file mode 100644 index 0000000000..6c41a55316 --- /dev/null +++ b/models/forgejo_migrations/v30.go @@ -0,0 +1,106 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package forgejo_migrations //nolint:revive + +import ( + "time" + + "forgejo.org/models/migrations/base" + "forgejo.org/modules/forgefed" + "forgejo.org/modules/log" + "forgejo.org/modules/timeutil" + + "xorm.io/xorm" +) + +func MigrateNormalizedFederatedURI(x *xorm.Engine) error { + // Update schema + type FederatedUser struct { + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"NOT NULL"` + ExternalID string `xorm:"UNIQUE(federation_user_mapping) NOT NULL"` + FederationHostID int64 `xorm:"UNIQUE(federation_user_mapping) NOT NULL"` + NormalizedOriginalURL string + } + type User struct { + ID int64 `xorm:"pk autoincr"` + NormalizedFederatedURI string + } + type FederationHost struct { + ID int64 `xorm:"pk autoincr"` + HostFqdn string `xorm:"host_fqdn UNIQUE INDEX VARCHAR(255) NOT NULL"` + NodeInfo NodeInfo `xorm:"extends NOT NULL"` + HostPort uint16 `xorm:"NOT NULL DEFAULT 443"` + HostSchema string `xorm:"NOT NULL DEFAULT 'https'"` + LatestActivity time.Time `xorm:"NOT NULL"` + Created timeutil.TimeStamp `xorm:"created"` + Updated timeutil.TimeStamp `xorm:"updated"` + } + if err := x.Sync(new(User), new(FederatedUser), new(FederationHost)); err != nil { + return err + } + + // Migrate + sessMigration := x.NewSession() + defer sessMigration.Close() + if err := sessMigration.Begin(); err != nil { + return err + } + federatedUsers := make([]*FederatedUser, 0) + err := sessMigration.OrderBy("id").Find(&federatedUsers) + if err != nil { + return err + } + + for _, federatedUser := range federatedUsers { + if federatedUser.NormalizedOriginalURL != "" { + log.Trace("migration[30]: FederatedUser was already migrated %v", federatedUser) + } else { + user := &User{} + has, err := sessMigration.Where("id=?", federatedUser.UserID).Get(user) + if err != nil { + return err + } + + if !has { + log.Debug("migration[30]: User missing for federated user: %v", federatedUser) + _, err := sessMigration.Delete(federatedUser) + if err != nil { + return err + } + } else { + // Migrate User.NormalizedFederatedURI -> FederatedUser.NormalizedOriginalUrl + sql := "UPDATE `federated_user` SET `normalized_original_url` = ? WHERE `id` = ?" + if _, err := sessMigration.Exec(sql, user.NormalizedFederatedURI, federatedUser.FederationHostID); err != nil { + return err + } + + // Migrate (Port, Schema) FederatedUser.NormalizedOriginalUrl -> FederationHost.(Port, Schema) + actorID, err := forgefed.NewActorID(user.NormalizedFederatedURI) + if err != nil { + return err + } + sql = "UPDATE `federation_host` SET `host_port` = ?, `host_schema` = ? WHERE `id` = ?" + if _, err := sessMigration.Exec(sql, actorID.HostPort, actorID.HostSchema, federatedUser.FederationHostID); err != nil { + return err + } + } + } + } + + if err := sessMigration.Commit(); err != nil { + return err + } + + // Drop User.NormalizedFederatedURI field in extra transaction + sessSchema := x.NewSession() + defer sessSchema.Close() + if err := sessSchema.Begin(); err != nil { + return err + } + if err := base.DropTableColumns(sessSchema, "user", "normalized_federated_uri"); err != nil { + return err + } + return sessSchema.Commit() +} diff --git a/models/forgejo_migrations/v30_test.go b/models/forgejo_migrations/v30_test.go new file mode 100644 index 0000000000..f826dab815 --- /dev/null +++ b/models/forgejo_migrations/v30_test.go @@ -0,0 +1,81 @@ +// Copyright 2025 The Forgejo Authors. +// SPDX-License-Identifier: GPL-3.0-or-later + +package forgejo_migrations //nolint:revive + +import ( + "testing" + "time" + + migration_tests "forgejo.org/models/migrations/test" + "forgejo.org/modules/timeutil" + + "github.com/stretchr/testify/require" + "xorm.io/xorm/schemas" +) + +func Test_MigrateNormalizedFederatedURI(t *testing.T) { + // Old structs + type User struct { + ID int64 `xorm:"pk autoincr"` + NormalizedFederatedURI string + } + type FederatedUser struct { + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"NOT NULL"` + ExternalID string `xorm:"UNIQUE(federation_user_mapping) NOT NULL"` + FederationHostID int64 `xorm:"UNIQUE(federation_user_mapping) NOT NULL"` + } + type FederationHost struct { + ID int64 `xorm:"pk autoincr"` + HostFqdn string `xorm:"host_fqdn UNIQUE INDEX VARCHAR(255) NOT NULL"` + SoftwareName string `xorm:"NOT NULL"` + LatestActivity time.Time `xorm:"NOT NULL"` + Created timeutil.TimeStamp `xorm:"created"` + Updated timeutil.TimeStamp `xorm:"updated"` + } + + // Prepare TestEnv + x, deferable := migration_tests.PrepareTestEnv(t, 0, + new(User), + new(FederatedUser), + new(FederationHost), + ) + defer deferable() + if x == nil || t.Failed() { + return + } + + // test for expected results + getColumn := func(tn, co string) *schemas.Column { + tables, err := x.DBMetas() + require.NoError(t, err) + var table *schemas.Table + for _, elem := range tables { + if elem.Name == tn { + table = elem + break + } + } + return table.GetColumn(co) + } + + require.NotNil(t, getColumn("user", "normalized_federated_uri")) + require.Nil(t, getColumn("federation_host", "host_port")) + require.Nil(t, getColumn("federation_host", "host_schema")) + cnt1, err := x.Table("federated_user").Count() + require.NoError(t, err) + require.Equal(t, int64(2), cnt1) + + require.NoError(t, MigrateNormalizedFederatedURI(x)) + + require.Nil(t, getColumn("user", "normalized_federated_uri")) + require.NotNil(t, getColumn("federation_host", "host_port")) + require.NotNil(t, getColumn("federation_host", "host_schema")) + cnt2, err := x.Table("federated_user").Count() + require.NoError(t, err) + require.Equal(t, int64(1), cnt2) + + // idempotent + require.NoError(t, MigrateNormalizedFederatedURI(x)) +} diff --git a/models/migrations/fixtures/Test_MigrateNormalizedFederatedURI/federated_user.yaml b/models/migrations/fixtures/Test_MigrateNormalizedFederatedURI/federated_user.yaml new file mode 100644 index 0000000000..dd2c347ae7 --- /dev/null +++ b/models/migrations/fixtures/Test_MigrateNormalizedFederatedURI/federated_user.yaml @@ -0,0 +1,10 @@ +- + id: 1 + user_id: 3 + federation_host_id: 1 + external_id: "18" +- + id: 2 + user_id: 999 + federation_host_id: 1 + external_id: "19" diff --git a/models/migrations/fixtures/Test_MigrateNormalizedFederatedURI/federation_host.yaml b/models/migrations/fixtures/Test_MigrateNormalizedFederatedURI/federation_host.yaml new file mode 100644 index 0000000000..46eeba9c69 --- /dev/null +++ b/models/migrations/fixtures/Test_MigrateNormalizedFederatedURI/federation_host.yaml @@ -0,0 +1,5 @@ +- + id: 1 + host_fqdn: "my.host.x" + software_name: forgejo + latest_activity: 2024-04-26 14:14:50 \ No newline at end of file diff --git a/models/migrations/fixtures/Test_MigrateNormalizedFederatedURI/user.yaml b/models/migrations/fixtures/Test_MigrateNormalizedFederatedURI/user.yaml new file mode 100644 index 0000000000..f6d8a182b3 --- /dev/null +++ b/models/migrations/fixtures/Test_MigrateNormalizedFederatedURI/user.yaml @@ -0,0 +1,3 @@ +- + id: 3 + normalized_federated_uri: "https://my.host.x/api/activitypub/user-id/18" \ No newline at end of file diff --git a/models/user/activitypub.go b/models/user/activitypub.go index 490615239c..816fd8a098 100644 --- a/models/user/activitypub.go +++ b/models/user/activitypub.go @@ -4,13 +4,10 @@ package user import ( - "context" "fmt" "net/url" - "forgejo.org/models/db" "forgejo.org/modules/setting" - "forgejo.org/modules/validation" ) // APActorID returns the IRI to the api endpoint of the user @@ -26,19 +23,3 @@ func (u *User) APActorID() string { func (u *User) APActorKeyID() string { return u.APActorID() + "#main-key" } - -func GetUserByFederatedURI(ctx context.Context, federatedURI string) (*User, error) { - user := new(User) - has, err := db.GetEngine(ctx).Where("normalized_federated_uri=?", federatedURI).Get(user) - if err != nil { - return nil, err - } else if !has { - return nil, nil - } - - if res, err := validation.IsValid(*user); !res { - return nil, err - } - - return user, nil -} diff --git a/models/user/federated_user.go b/models/user/federated_user.go index d32f42867d..a53e6e4bb8 100644 --- a/models/user/federated_user.go +++ b/models/user/federated_user.go @@ -1,30 +1,30 @@ -// Copyright 2024 The Forgejo Authors. All rights reserved. +// Copyright 2024, 2025 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT package user import ( - "context" "database/sql" - "forgejo.org/models/db" "forgejo.org/modules/validation" ) type FederatedUser struct { - ID int64 `xorm:"pk autoincr"` - UserID int64 `xorm:"NOT NULL"` - ExternalID string `xorm:"UNIQUE(federation_user_mapping) NOT NULL"` - FederationHostID int64 `xorm:"UNIQUE(federation_user_mapping) NOT NULL"` - KeyID sql.NullString `xorm:"key_id UNIQUE"` - PublicKey sql.Null[sql.RawBytes] `xorm:"BLOB"` + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"NOT NULL"` + ExternalID string `xorm:"UNIQUE(federation_user_mapping) NOT NULL"` + FederationHostID int64 `xorm:"UNIQUE(federation_user_mapping) NOT NULL"` + KeyID sql.NullString `xorm:"key_id UNIQUE"` + PublicKey sql.Null[sql.RawBytes] `xorm:"BLOB"` + NormalizedOriginalURL string // This field ist just to keep original information. Pls. do not use for search or as ID! } -func NewFederatedUser(userID int64, externalID string, federationHostID int64) (FederatedUser, error) { +func NewFederatedUser(userID int64, externalID string, federationHostID int64, normalizedOriginalURL string) (FederatedUser, error) { result := FederatedUser{ - UserID: userID, - ExternalID: externalID, - FederationHostID: federationHostID, + UserID: userID, + ExternalID: externalID, + FederationHostID: federationHostID, + NormalizedOriginalURL: normalizedOriginalURL, } if valid, err := validation.IsValid(result); !valid { return FederatedUser{}, err @@ -32,30 +32,6 @@ func NewFederatedUser(userID int64, externalID string, federationHostID int64) ( return result, nil } -func getFederatedUserFromDB(ctx context.Context, searchKey, searchValue any) (*FederatedUser, error) { - federatedUser := new(FederatedUser) - has, err := db.GetEngine(ctx).Where(searchKey, searchValue).Get(federatedUser) - if err != nil { - return nil, err - } else if !has { - return nil, nil - } - - if res, err := validation.IsValid(*federatedUser); !res { - return nil, err - } - - return federatedUser, nil -} - -func GetFederatedUserByKeyID(ctx context.Context, keyID string) (*FederatedUser, error) { - return getFederatedUserFromDB(ctx, "key_id=?", keyID) -} - -func GetFederatedUserByUserID(ctx context.Context, userID int64) (*FederatedUser, error) { - return getFederatedUserFromDB(ctx, "user_id=?", userID) -} - func (user FederatedUser) Validate() []string { var result []string result = append(result, validation.ValidateNotEmpty(user.UserID, "UserID")...) diff --git a/models/user/user.go b/models/user/user.go index 26074a0998..5a8c1e9d73 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -1,6 +1,6 @@ // Copyright 2014 The Gogs Authors. All rights reserved. // Copyright 2019 The Gitea Authors. All rights reserved. -// Copyright 2024 The Forgejo Authors. All rights reserved. +// Copyright 2024, 2025 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT package user @@ -135,9 +135,6 @@ type User struct { AvatarEmail string `xorm:"NOT NULL"` UseCustomAvatar bool - // For federation - NormalizedFederatedURI string - // Counters NumFollowers int NumFollowing int `xorm:"NOT NULL DEFAULT 0"` diff --git a/models/user/user_repository.go b/models/user/user_repository.go index 172bf7c8b4..299d3af64a 100644 --- a/models/user/user_repository.go +++ b/models/user/user_repository.go @@ -50,9 +50,7 @@ func CreateFederatedUser(ctx context.Context, user *User, federatedUser *Federat return committer.Commit() } -func FindFederatedUser(ctx context.Context, externalID string, - federationHostID int64, -) (*User, *FederatedUser, error) { +func FindFederatedUser(ctx context.Context, externalID string, federationHostID int64) (*User, *FederatedUser, error) { federatedUser := new(FederatedUser) user := new(User) has, err := db.GetEngine(ctx).Where("external_id=? and federation_host_id=?", externalID, federationHostID).Get(federatedUser) @@ -77,6 +75,32 @@ func FindFederatedUser(ctx context.Context, externalID string, return user, federatedUser, nil } +func FindFederatedUserByKeyID(ctx context.Context, keyID string) (*User, *FederatedUser, error) { + federatedUser := new(FederatedUser) + user := new(User) + has, err := db.GetEngine(ctx).Where("key_id=?", keyID).Get(federatedUser) + if err != nil { + return nil, nil, err + } else if !has { + return nil, nil, nil + } + has, err = db.GetEngine(ctx).ID(federatedUser.UserID).Get(user) + if err != nil { + return nil, nil, err + } else if !has { + return nil, nil, fmt.Errorf("User %v for federated user is missing", federatedUser.UserID) + } + + if res, err := validation.IsValid(*user); !res { + return nil, nil, err + } + if res, err := validation.IsValid(*federatedUser); !res { + return nil, nil, err + } + + return user, federatedUser, nil +} + func DeleteFederatedUser(ctx context.Context, userID int64) error { _, err := db.GetEngine(ctx).Delete(&FederatedUser{UserID: userID}) return err diff --git a/modules/forgefed/actor.go b/modules/forgefed/actor.go index c01175f0f6..6fde2babde 100644 --- a/modules/forgefed/actor.go +++ b/modules/forgefed/actor.go @@ -1,4 +1,4 @@ -// Copyright 2023, 2024 The Forgejo Authors. All rights reserved. +// Copyright 2023, 2024, 2025 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT package forgefed @@ -6,6 +6,7 @@ package forgefed import ( "fmt" "net/url" + "strconv" "strings" "forgejo.org/modules/validation" @@ -15,13 +16,14 @@ import ( // ----------------------------- ActorID -------------------------------------------- type ActorID struct { - ID string - Source string - Schema string - Path string - Host string - Port string - UnvalidatedInput string + ID string + Source string + HostSchema string + Path string + Host string + HostPort uint16 + UnvalidatedInput string + IsPortSupplemented bool } // Factory function for ActorID. Created struct is asserted to be valid @@ -40,20 +42,23 @@ func NewActorID(uri string) (ActorID, error) { func (id ActorID) AsURI() string { var result string - if id.Port == "" { - result = fmt.Sprintf("%s://%s/%s/%s", id.Schema, id.Host, id.Path, id.ID) + + if id.IsPortSupplemented { + result = fmt.Sprintf("%s://%s/%s/%s", id.HostSchema, id.Host, id.Path, id.ID) } else { - result = fmt.Sprintf("%s://%s:%s/%s/%s", id.Schema, id.Host, id.Port, id.Path, id.ID) + result = fmt.Sprintf("%s://%s:%d/%s/%s", id.HostSchema, id.Host, id.HostPort, id.Path, id.ID) } + 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.HostPort, "hostPort")...) + result = append(result, validation.ValidateNotEmpty(id.HostSchema, "hostSchema")...) result = append(result, validation.ValidateNotEmpty(id.UnvalidatedInput, "unvalidatedInput")...) if id.UnvalidatedInput != id.AsURI() { @@ -104,12 +109,14 @@ func (id PersonID) Validate() []string { result := id.ActorID.Validate() result = append(result, validation.ValidateNotEmpty(id.Source, "source")...) result = append(result, validation.ValidateOneOf(id.Source, []any{"forgejo", "gitea"}, "Source")...) + switch id.Source { case "forgejo", "gitea": if strings.ToLower(id.Path) != "api/v1/activitypub/user-id" && strings.ToLower(id.Path) != "api/activitypub/user-id" { result = append(result, fmt.Sprintf("path: %q has to be a person specific api path", id.Path)) } } + return result } @@ -168,6 +175,8 @@ func removeEmptyStrings(ls []string) []string { return rs } +// ----------------------------- newActorID -------------------------------------------- + func newActorID(uri string) (ActorID, error) { validatedURI, err := url.ParseRequestURI(uri) if err != nil { @@ -179,15 +188,27 @@ func newActorID(uri string) (ActorID, error) { } length := len(pathWithActorID) pathWithoutActorID := strings.Join(pathWithActorID[0:length-1], "/") - id := pathWithActorID[length-1] + id := strings.ToLower(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 = uri + result.HostSchema = strings.ToLower(validatedURI.Scheme) + result.Host = strings.ToLower(validatedURI.Hostname()) + result.Path = strings.ToLower(pathWithoutActorID) + + if validatedURI.Port() == "" && result.HostSchema == "https" { + result.IsPortSupplemented = true + result.HostPort = 443 + } else if validatedURI.Port() == "" && result.HostSchema == "http" { + result.IsPortSupplemented = true + result.HostPort = 80 + } else { + numPort, _ := strconv.ParseUint(validatedURI.Port(), 10, 16) + result.HostPort = uint16(numPort) + } + + result.UnvalidatedInput = strings.ToLower(uri) + return result, nil } diff --git a/modules/forgefed/actor_test.go b/modules/forgefed/actor_test.go index e2157a96e4..5315d0b4de 100644 --- a/modules/forgefed/actor_test.go +++ b/modules/forgefed/actor_test.go @@ -1,4 +1,4 @@ -// Copyright 2023, 2024 The Forgejo Authors. All rights reserved. +// Copyright 2023, 2024, 2025 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT package forgefed @@ -18,11 +18,13 @@ func TestNewPersonId(t *testing.T) { expected := PersonID{} expected.ID = "1" expected.Source = "forgejo" - expected.Schema = "https" + expected.HostSchema = "https" expected.Path = "api/v1/activitypub/user-id" expected.Host = "an.other.host" - expected.Port = "" + expected.HostPort = 443 + expected.IsPortSupplemented = true expected.UnvalidatedInput = "https://an.other.host/api/v1/activitypub/user-id/1" + sut, _ := NewPersonID("https://an.other.host/api/v1/activitypub/user-id/1", "forgejo") if sut != expected { t.Errorf("expected: %v\n but was: %v\n", expected, sut) @@ -31,15 +33,47 @@ func TestNewPersonId(t *testing.T) { expected = PersonID{} expected.ID = "1" expected.Source = "forgejo" - expected.Schema = "https" + expected.HostSchema = "https" expected.Path = "api/v1/activitypub/user-id" expected.Host = "an.other.host" - expected.Port = "443" + expected.HostPort = 443 + expected.IsPortSupplemented = false expected.UnvalidatedInput = "https://an.other.host:443/api/v1/activitypub/user-id/1" + sut, _ = NewPersonID("https://an.other.host:443/api/v1/activitypub/user-id/1", "forgejo") if sut != expected { t.Errorf("expected: %v\n but was: %v\n", expected, sut) } + + expected = PersonID{} + expected.ID = "1" + expected.Source = "forgejo" + expected.HostSchema = "http" + expected.Path = "api/v1/activitypub/user-id" + expected.Host = "an.other.host" + expected.HostPort = 80 + expected.IsPortSupplemented = false + expected.UnvalidatedInput = "http://an.other.host:80/api/v1/activitypub/user-id/1" + + sut, _ = NewPersonID("http://an.other.host:80/api/v1/activitypub/user-id/1", "forgejo") + if sut != expected { + t.Errorf("expected: %v\n but was: %v\n", expected, sut) + } + + expected = PersonID{} + expected.ID = "1" + expected.Source = "forgejo" + expected.HostSchema = "https" + expected.Path = "api/v1/activitypub/user-id" + expected.Host = "an.other.host" + expected.HostPort = 443 + expected.IsPortSupplemented = false + expected.UnvalidatedInput = "https://an.other.host:443/api/v1/activitypub/user-id/1" + + sut, _ = NewPersonID("HTTPS://an.other.host:443/api/v1/activitypub/user-id/1", "forgejo") + if sut != expected { + t.Errorf("expected: %v\n but was: %v\n", expected, sut) + } } func TestNewRepositoryId(t *testing.T) { @@ -47,10 +81,11 @@ func TestNewRepositoryId(t *testing.T) { expected := RepositoryID{} expected.ID = "1" expected.Source = "forgejo" - expected.Schema = "http" + expected.HostSchema = "http" expected.Path = "api/activitypub/repository-id" expected.Host = "localhost" - expected.Port = "3000" + expected.HostPort = 3000 + expected.IsPortSupplemented = false expected.UnvalidatedInput = "http://localhost:3000/api/activitypub/repository-id/1" sut, _ := NewRepositoryID("http://localhost:3000/api/activitypub/repository-id/1", "forgejo") if sut != expected { @@ -61,10 +96,11 @@ func TestNewRepositoryId(t *testing.T) { func TestActorIdValidation(t *testing.T) { sut := ActorID{} sut.Source = "forgejo" - sut.Schema = "https" + sut.HostSchema = "https" sut.Path = "api/v1/activitypub/user-id" sut.Host = "an.other.host" - sut.Port = "" + sut.HostPort = 443 + sut.IsPortSupplemented = true sut.UnvalidatedInput = "https://an.other.host/api/v1/activitypub/user-id/" if sut.Validate()[0] != "userId should not be empty" { t.Errorf("validation error expected but was: %v\n", sut.Validate()) @@ -73,10 +109,11 @@ func TestActorIdValidation(t *testing.T) { sut = ActorID{} sut.ID = "1" sut.Source = "forgejo" - sut.Schema = "https" + sut.HostSchema = "https" sut.Path = "api/v1/activitypub/user-id" sut.Host = "an.other.host" - sut.Port = "" + sut.HostPort = 443 + sut.IsPortSupplemented = true sut.UnvalidatedInput = "https://an.other.host/api/v1/activitypub/user-id/1?illegal=action" 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\"" { t.Errorf("validation error expected but was: %v\n", sut.Validate()[0]) @@ -87,10 +124,11 @@ func TestPersonIdValidation(t *testing.T) { sut := PersonID{} sut.ID = "1" sut.Source = "forgejo" - sut.Schema = "https" + sut.HostSchema = "https" sut.Path = "path" sut.Host = "an.other.host" - sut.Port = "" + sut.HostPort = 443 + sut.IsPortSupplemented = true sut.UnvalidatedInput = "https://an.other.host/path/1" _, err := validation.IsValid(sut) @@ -101,10 +139,11 @@ func TestPersonIdValidation(t *testing.T) { sut = PersonID{} sut.ID = "1" sut.Source = "forgejox" - sut.Schema = "https" + sut.HostSchema = "https" sut.Path = "api/v1/activitypub/user-id" sut.Host = "an.other.host" - sut.Port = "" + sut.HostPort = 443 + sut.IsPortSupplemented = true 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()[0]) @@ -125,12 +164,10 @@ func TestWebfingerId(t *testing.T) { func TestShouldThrowErrorOnInvalidInput(t *testing.T) { var err any - // TODO: remove after test - //_, err = NewPersonId("", "forgejo") - //if err == nil { - // t.Errorf("empty input should be invalid.") - //} - + _, err = NewPersonID("", "forgejo") + if err == nil { + t.Errorf("empty input should be invalid.") + } _, err = NewPersonID("http://localhost:3000/api/v1/something", "forgejo") if err == nil { t.Errorf("localhost uris are not external") @@ -155,7 +192,6 @@ func TestShouldThrowErrorOnInvalidInput(t *testing.T) { if err == nil { t.Errorf("uri may not contain unparsed elements") } - _, err = NewPersonID("https://an.other.host/api/v1/activitypub/user-id/1", "forgejo") if err != nil { t.Errorf("this uri should be valid but was: %v", err) diff --git a/modules/forgefed/nodeinfo.go b/modules/forgefed/nodeinfo.go index b22d2959d4..f9ddaa512f 100644 --- a/modules/forgefed/nodeinfo.go +++ b/modules/forgefed/nodeinfo.go @@ -1,4 +1,4 @@ -// Copyright 2023 The Forgejo Authors. All rights reserved. +// Copyright 2023, 2025 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT package forgefed @@ -10,10 +10,10 @@ import ( 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) + if id.HostPort == 0 { + result = fmt.Sprintf("%s://%s/%s", id.HostSchema, id.Host, wellKnownPath) } else { - result = fmt.Sprintf("%s://%s:%s/%s", id.Schema, id.Host, id.Port, wellKnownPath) + result = fmt.Sprintf("%s://%s:%d/%s", id.HostSchema, id.Host, id.HostPort, wellKnownPath) } return result } diff --git a/modules/validation/validatable.go b/modules/validation/validatable.go index bc565bd194..d2c5553259 100644 --- a/modules/validation/validatable.go +++ b/modules/validation/validatable.go @@ -1,5 +1,4 @@ -// Copyright 2024 The Forgejo Authors. All rights reserved. -// Copyright 2023 The Forgejo Authors. All rights reserved. +// Copyright 2023, 2024, 2025 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT package validation @@ -33,9 +32,9 @@ type Validateable interface { } func IsValid(v Validateable) (bool, error) { - if err := v.Validate(); len(err) > 0 { + if valdationErrors := v.Validate(); len(valdationErrors) > 0 { typeof := reflect.TypeOf(v) - errString := strings.Join(err, "\n") + errString := strings.Join(valdationErrors, "\n") return false, ErrNotValid{fmt.Sprint(typeof, ": ", errString)} } @@ -53,6 +52,10 @@ func ValidateNotEmpty(value any, name string) []string { if v.IsZero() { isValid = false } + case uint16: + if v == 0 { + isValid = false + } case int64: if v == 0 { isValid = false diff --git a/routers/api/v1/activitypub/reqsignature.go b/routers/api/v1/activitypub/reqsignature.go index 5872d951cf..2d9cd3b1bb 100644 --- a/routers/api/v1/activitypub/reqsignature.go +++ b/routers/api/v1/activitypub/reqsignature.go @@ -36,33 +36,25 @@ func decodePublicKeyPem(pubKeyPem string) ([]byte, error) { } func getFederatedUser(ctx *gitea_context.APIContext, person *ap.Person, federationHost *forgefed.FederationHost) (*user.FederatedUser, error) { - dbUser, err := user.GetUserByFederatedURI(ctx, person.ID.String()) - if err != nil { - return nil, err - } - - if dbUser != nil { - federatedUser, err := user.GetFederatedUserByUserID(ctx, dbUser.ID) - if err != nil { - return nil, err - } - - if federatedUser != nil { - return federatedUser, nil - } - } - personID, err := fm.NewPersonID(person.ID.String(), string(federationHost.NodeInfo.SoftwareName)) if err != nil { return nil, err } - - _, federatedUser, err := federation.CreateUserFromAP(ctx, personID, federationHost.ID) + _, federatedUser, err := user.FindFederatedUser(ctx, personID.ID, federationHost.ID) if err != nil { return nil, err } - return federatedUser, nil + if federatedUser != nil { + return federatedUser, nil + } + + _, newFederatedUser, err := federation.CreateUserFromAP(ctx, personID, federationHost.ID) + if err != nil { + return nil, err + } + + return newFederatedUser, nil } func storePublicKey(ctx *gitea_context.APIContext, person *ap.Person, pubKeyBytes []byte) error { @@ -159,7 +151,7 @@ func verifyHTTPSignatures(ctx *gitea_context.APIContext) (authenticated bool, er // 2. Fetch the public key of the other actor // Try if the signing actor is an already known federated user - federationUser, err := user.GetFederatedUserByKeyID(ctx, idIRI.String()) + _, federationUser, err := user.FindFederatedUserByKeyID(ctx, idIRI.String()) if err != nil { return false, err } diff --git a/services/federation/federation_service.go b/services/federation/federation_service.go index b8c471bfbb..3d6e219ffc 100644 --- a/services/federation/federation_service.go +++ b/services/federation/federation_service.go @@ -1,4 +1,4 @@ -// Copyright 2024 The Forgejo Authors. All rights reserved. +// Copyright 2024, 2025 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT package federation @@ -129,7 +129,7 @@ func CreateFederationHostFromAP(ctx context.Context, actorID fm.ActorID) (*forge return nil, err } - result, err := forgefed.NewFederationHost(nodeInfo, actorID.Host) + result, err := forgefed.NewFederationHost(actorID.Host, nodeInfo, actorID.HostPort, actorID.HostSchema) if err != nil { return nil, err } @@ -148,7 +148,7 @@ func GetFederationHostForURI(ctx context.Context, actorURI string) (*forgefed.Fe if err != nil { return nil, err } - federationHost, err := forgefed.FindFederationHostByFqdn(ctx, rawActorID.Host) + federationHost, err := forgefed.FindFederationHostByFqdnAndPort(ctx, rawActorID.Host, rawActorID.HostPort) if err != nil { return nil, err } @@ -221,12 +221,12 @@ func CreateUserFromAP(ctx context.Context, personID fm.PersonID, federationHostI LoginName: loginName, Type: user.UserTypeRemoteUser, IsAdmin: false, - NormalizedFederatedURI: personID.AsURI(), } federatedUser := user.FederatedUser{ - ExternalID: personID.ID, - FederationHostID: federationHostID, + ExternalID: personID.ID, + FederationHostID: federationHostID, + NormalizedOriginalURL: personID.AsURI(), } err = user.CreateFederatedUser(ctx, &newUser, &federatedUser) diff --git a/tests/integration/api_federation_httpsig_test.go b/tests/integration/api_federation_httpsig_test.go index 9d66f25102..a7a5ae26ed 100644 --- a/tests/integration/api_federation_httpsig_test.go +++ b/tests/integration/api_federation_httpsig_test.go @@ -64,7 +64,7 @@ func TestFederationHttpSigValidation(t *testing.T) { assert.NotNil(t, host) assert.True(t, host.PublicKey.Valid) - user, err := user.GetFederatedUserByKeyID(db.DefaultContext, actorKeyID) + _, user, err := user.FindFederatedUserByKeyID(db.DefaultContext, actorKeyID) require.NoError(t, err) assert.NotNil(t, user) assert.True(t, user.PublicKey.Valid)