diff --git a/models/repo/following_repo.go b/models/repo/following_repo.go new file mode 100644 index 0000000000..85b96aa147 --- /dev/null +++ b/models/repo/following_repo.go @@ -0,0 +1,39 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "code.gitea.io/gitea/modules/validation" +) + +// FollowingRepo represents a federated Repository Actor connected with a local Repo +type FollowingRepo struct { + ID int64 `xorm:"pk autoincr"` + RepoID int64 `xorm:"UNIQUE(federation_repo_mapping) NOT NULL"` + ExternalID string `xorm:"UNIQUE(federation_repo_mapping) NOT NULL"` + FederationHostID int64 `xorm:"UNIQUE(federation_repo_mapping) NOT NULL"` + URI string +} + +func NewFollowingRepo(repoID int64, externalID string, federationHostID int64, uri string) (FollowingRepo, error) { + result := FollowingRepo{ + RepoID: repoID, + ExternalID: externalID, + FederationHostID: federationHostID, + URI: uri, + } + if valid, err := validation.IsValid(result); !valid { + return FollowingRepo{}, err + } + return result, nil +} + +func (user FollowingRepo) Validate() []string { + var result []string + result = append(result, validation.ValidateNotEmpty(user.RepoID, "UserID")...) + result = append(result, validation.ValidateNotEmpty(user.ExternalID, "ExternalID")...) + result = append(result, validation.ValidateNotEmpty(user.FederationHostID, "FederationHostID")...) + result = append(result, validation.ValidateNotEmpty(user.URI, "Uri")...) + return result +} diff --git a/models/repo/following_repo_test.go b/models/repo/following_repo_test.go new file mode 100644 index 0000000000..d0dd0a31a7 --- /dev/null +++ b/models/repo/following_repo_test.go @@ -0,0 +1,31 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "testing" + + "code.gitea.io/gitea/modules/validation" +) + +func Test_FollowingRepoValidation(t *testing.T) { + sut := FollowingRepo{ + RepoID: 12, + ExternalID: "12", + FederationHostID: 1, + URI: "http://localhost:3000/api/v1/activitypub/repo-id/1", + } + if res, err := validation.IsValid(sut); !res { + t.Errorf("sut should be valid but was %q", err) + } + + sut = FollowingRepo{ + ExternalID: "12", + FederationHostID: 1, + URI: "http://localhost:3000/api/v1/activitypub/repo-id/1", + } + if res, _ := validation.IsValid(sut); res { + t.Errorf("sut should be invalid") + } +} diff --git a/models/repo/repo.go b/models/repo/repo.go index 28471159d8..6db7c30513 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -1,4 +1,5 @@ // Copyright 2021 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT package repo @@ -342,6 +343,11 @@ func (repo *Repository) APIURL() string { return setting.AppURL + "api/v1/repos/" + url.PathEscape(repo.OwnerName) + "/" + url.PathEscape(repo.Name) } +// APActorID returns the activitypub repository API URL +func (repo *Repository) APActorID() string { + return fmt.Sprintf("%vapi/v1/activitypub/repository-id/%v", setting.AppURL, url.PathEscape(fmt.Sprint(repo.ID))) +} + // GetCommitsCountCacheKey returns cache key used for commits count caching. func (repo *Repository) GetCommitsCountCacheKey(contextName string, isRef bool) string { var prefix string diff --git a/models/repo/repo_repository.go b/models/repo/repo_repository.go new file mode 100644 index 0000000000..6780165a38 --- /dev/null +++ b/models/repo/repo_repository.go @@ -0,0 +1,60 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: MIT +package repo + +import ( + "context" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/validation" +) + +func init() { + db.RegisterModel(new(FollowingRepo)) +} + +func FindFollowingReposByRepoID(ctx context.Context, repoID int64) ([]*FollowingRepo, error) { + maxFollowingRepos := 10 + sess := db.GetEngine(ctx).Where("repo_id=?", repoID) + sess = sess.Limit(maxFollowingRepos, 0) + followingRepoList := make([]*FollowingRepo, 0, maxFollowingRepos) + err := sess.Find(&followingRepoList) + if err != nil { + return make([]*FollowingRepo, 0, maxFollowingRepos), err + } + for _, followingRepo := range followingRepoList { + if res, err := validation.IsValid(*followingRepo); !res { + return make([]*FollowingRepo, 0, maxFollowingRepos), err + } + } + return followingRepoList, nil +} + +func StoreFollowingRepos(ctx context.Context, localRepoID int64, followingRepoList []*FollowingRepo) error { + for _, followingRepo := range followingRepoList { + if res, err := validation.IsValid(*followingRepo); !res { + return err + } + } + + // Begin transaction + ctx, committer, err := db.TxContext((ctx)) + if err != nil { + return err + } + defer committer.Close() + + _, err = db.GetEngine(ctx).Where("repo_id=?", localRepoID).Delete(FollowingRepo{}) + if err != nil { + return err + } + for _, followingRepo := range followingRepoList { + _, err = db.GetEngine(ctx).Insert(followingRepo) + if err != nil { + return err + } + } + + // Commit transaction + return committer.Commit() +} diff --git a/models/repo/repo_test.go b/models/repo/repo_test.go index 1a870224bf..a279478177 100644 --- a/models/repo/repo_test.go +++ b/models/repo/repo_test.go @@ -1,4 +1,5 @@ // Copyright 2017 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT package repo_test @@ -217,3 +218,12 @@ func TestComposeSSHCloneURL(t *testing.T) { setting.SSH.Port = 123 assert.Equal(t, "ssh://git@[::1]:123/user/repo.git", repo_model.ComposeSSHCloneURL("user", "repo")) } + +func TestAPActorID(t *testing.T) { + repo := repo_model.Repository{ID: 1} + url := repo.APActorID() + expected := "https://try.gitea.io/api/v1/activitypub/repository-id/1" + if url != expected { + t.Errorf("unexpected APActorID, expected: %q, actual: %q", expected, url) + } +} diff --git a/modules/templates/helper.go b/modules/templates/helper.go index 2268b8b0fb..4dc1f1938c 100644 --- a/modules/templates/helper.go +++ b/modules/templates/helper.go @@ -1,3 +1,4 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. // Copyright 2018 The Gitea Authors. All rights reserved. // Copyright 2014 The Gogs Authors. All rights reserved. // SPDX-License-Identifier: MIT @@ -156,6 +157,9 @@ func NewFuncMap() template.FuncMap { "MermaidMaxSourceCharacters": func() int { return setting.MermaidMaxSourceCharacters }, + "FederationEnabled": func() bool { + return setting.Federation.Enabled + }, // ----------------------------------------------------------------- // render diff --git a/options/locale/locale_de-DE.ini b/options/locale/locale_de-DE.ini index d8a80311ee..01178d23d2 100644 --- a/options/locale/locale_de-DE.ini +++ b/options/locale/locale_de-DE.ini @@ -1131,6 +1131,7 @@ form.reach_limit_of_creation_1=Du hast bereits dein Limit von %d Repository erre form.reach_limit_of_creation_n=Du hast bereits dein Limit von %d Repositorys erreicht. form.name_reserved=Der Repository-Name „%s“ ist reserviert. form.name_pattern_not_allowed=Das Muster „%s“ ist in Repository-Namen nicht erlaubt. +form.string_too_long=Der angegebene String ist länger als %d Zeichen. need_auth=Authentifizierung migrate_options=Migrationsoptionen @@ -2060,6 +2061,10 @@ settings.collaboration.undefined=Nicht definiert settings.hooks=Webhooks settings.githooks=Git-Hooks settings.basic_settings=Grundeinstellungen +settings.federation_settings=Föderationseinstellungen +settings.federation_apapiurl=Föderierungs-URL dieses Repositories. Kopiere sie und füge sie in die Föderationseinstellungen eines anderen Repository ein als dem Repository folgendes Repository. +settings.federation_following_repos=URLs der Repos, die diesem Repo folgen. Getrennt mittels ";", keine Leerzeichen. +settings.federation_not_enabled=Föderierung ist auf deiner Instanz nicht aktiviert. settings.mirror_settings=Spiegeleinstellungen settings.mirror_settings.docs=Richte dein Repository so ein, dass es automatisch Commits, Tags und Branches mit einem anderen Repository synchronisieren kann. settings.mirror_settings.docs.disabled_pull_mirror.instructions=Richte dein Projekt so ein, dass es automatisch Commits, Tags und Branches in ein anderes Repository pusht. Pull-Spiegel wurden von deinem Website-Administrator deaktiviert. diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index ceab6d866a..e0b6c7b981 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1145,6 +1145,8 @@ form.reach_limit_of_creation_1 = The owner has already reached the limit of %d r form.reach_limit_of_creation_n = The owner has already reached the limit of %d repositories. form.name_reserved = The repository name "%s" is reserved. form.name_pattern_not_allowed = The pattern "%s" is not allowed in a repository name. +form.string_too_long=The given string is longer than %d characters. + need_auth = Authorization migrate_options = Migration options @@ -2106,6 +2108,10 @@ settings.collaboration.undefined = Undefined settings.hooks = Webhooks settings.githooks = Git hooks settings.basic_settings = Basic settings +settings.federation_settings=Federation Settings +settings.federation_apapiurl=Federation URL of this repository. Copy and paste this into Federation Settings of another repository as an URL of a Following Repository. +settings.federation_following_repos=URLs of Following Repositories. Separated by ";", no whitespace. +settings.federation_not_enabled=Federation is not enabled on your instance. settings.mirror_settings = Mirror settings settings.mirror_settings.docs = Set up your repository to automatically synchronize commits, tags and branches with another repository. settings.mirror_settings.docs.disabled_pull_mirror.instructions = Set up your project to automatically push commits, tags and branches to another repository. Pull mirrors have been disabled by your site administrator. diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index a7d4e75ff6..b29ab3c4a9 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -1,5 +1,6 @@ // Copyright 2014 The Gogs Authors. All rights reserved. // Copyright 2018 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT package setting @@ -33,6 +34,7 @@ import ( actions_service "code.gitea.io/gitea/services/actions" asymkey_service "code.gitea.io/gitea/services/asymkey" "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/federation" "code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/migrations" mirror_service "code.gitea.io/gitea/services/mirror" @@ -383,6 +385,41 @@ func SettingsPost(ctx *context.Context) { ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) ctx.Redirect(repo.Link() + "/settings") + case "federation": + if !setting.Federation.Enabled { + ctx.NotFound("", nil) + ctx.Flash.Info(ctx.Tr("repo.settings.federation_not_enabled")) + return + } + // ToDo: Rename to followingRepos + federationRepos := strings.TrimSpace(form.FederationRepos) + federationRepos = strings.TrimSuffix(federationRepos, ";") + + maxFollowingRepoStrLength := 2048 + errs := validation.ValidateMaxLen(federationRepos, maxFollowingRepoStrLength, "federationRepos") + if len(errs) > 0 { + ctx.Data["ERR_FederationRepos"] = true + ctx.Flash.Error(ctx.Tr("repo.form.string_too_long", maxFollowingRepoStrLength)) + ctx.Redirect(repo.Link() + "/settings") + return + } + + federationRepoSplit := []string{} + if federationRepos != "" { + federationRepoSplit = strings.Split(federationRepos, ";") + } + for idx, repo := range federationRepoSplit { + federationRepoSplit[idx] = strings.TrimSpace(repo) + } + + if _, _, err := federation.StoreFollowingRepoList(ctx, ctx.Repo.Repository.ID, federationRepoSplit); err != nil { + ctx.ServerError("UpdateRepository", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success")) + ctx.Redirect(repo.Link() + "/settings") + case "mirror": if !setting.Mirror.Enabled || !repo.IsMirror || repo.IsArchived { ctx.NotFound("", nil) diff --git a/services/context/repo.go b/services/context/repo.go index 54453cc2d9..e4cacbc53c 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -1,3 +1,4 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. // Copyright 2014 The Gogs Authors. All rights reserved. // Copyright 2017 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT @@ -386,6 +387,21 @@ func repoAssignment(ctx *Context, repo *repo_model.Repository) { ctx.Data["HasAccess"] = true ctx.Data["Permission"] = &ctx.Repo.Permission + followingRepoList, err := repo_model.FindFollowingReposByRepoID(ctx, repo.ID) + if err == nil { + followingRepoString := "" + for idx, followingRepo := range followingRepoList { + if idx > 0 { + followingRepoString += ";" + } + followingRepoString += followingRepo.URI + } + ctx.Data["FollowingRepos"] = followingRepoString + } else if err != repo_model.ErrMirrorNotExist { + ctx.ServerError("FindFollowingRepoByRepoID", err) + return + } + if repo.IsMirror { pullMirror, err := repo_model.GetMirrorByRepoID(ctx, repo.ID) if err == nil { @@ -566,6 +582,7 @@ func RepoAssignment(ctx *Context) context.CancelFunc { ctx.Data["Title"] = owner.Name + "/" + repo.Name ctx.Data["Repository"] = repo + ctx.Data["RepositoryAPActorID"] = repo.APActorID() ctx.Data["Owner"] = ctx.Repo.Repository.Owner ctx.Data["IsRepositoryOwner"] = ctx.Repo.IsOwner() ctx.Data["IsRepositoryAdmin"] = ctx.Repo.IsAdmin() diff --git a/services/federation/federation_service.go b/services/federation/federation_service.go index be2dc2eb6a..9f99c04e9a 100644 --- a/services/federation/federation_service.go +++ b/services/federation/federation_service.go @@ -212,3 +212,34 @@ func CreateUserFromAP(ctx context.Context, personID fm.PersonID, federationHostI return &newUser, &federatedUser, nil } + +// Create or update a list of FollowingRepo structs +func StoreFollowingRepoList(ctx context.Context, localRepoID int64, followingRepoList []string) (int, string, error) { + followingRepos := make([]*repo.FollowingRepo, 0, len(followingRepoList)) + for _, uri := range followingRepoList { + federationHost, err := GetFederationHostForURI(ctx, uri) + if err != nil { + return http.StatusInternalServerError, "Wrong FederationHost", err + } + followingRepoID, err := fm.NewRepositoryID(uri, string(federationHost.NodeInfo.SoftwareName)) + if err != nil { + return http.StatusNotAcceptable, "Invalid federated repo", err + } + followingRepo, err := repo.NewFollowingRepo(localRepoID, followingRepoID.ID, federationHost.ID, uri) + if err != nil { + return http.StatusNotAcceptable, "Invalid federated repo", err + } + followingRepos = append(followingRepos, &followingRepo) + } + + if err := repo.StoreFollowingRepos(ctx, localRepoID, followingRepos); err != nil { + return 0, "", err + } + + return 0, "", nil +} + +func DeleteFollowingRepos(ctx context.Context, localRepoID int64) error { + return repo.StoreFollowingRepos(ctx, localRepoID, []*repo.FollowingRepo{}) +} + diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index e4fcf8e0c0..1bc06b1b9a 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -1,3 +1,4 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. // Copyright 2014 The Gogs Authors. All rights reserved. // Copyright 2017 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT @@ -113,6 +114,7 @@ type RepoSettingForm struct { RepoName string `binding:"Required;AlphaDashDot;MaxSize(100)"` Description string `binding:"MaxSize(2048)"` Website string `binding:"ValidUrl;MaxSize(1024)"` + FederationRepos string Interval string MirrorAddress string MirrorUsername string diff --git a/services/repository/repository.go b/services/repository/repository.go index d28200c0ad..742d93dd2e 100644 --- a/services/repository/repository.go +++ b/services/repository/repository.go @@ -1,3 +1,4 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. // Copyright 2019 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT @@ -21,6 +22,7 @@ import ( repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" + federation_service "code.gitea.io/gitea/services/federation" notify_service "code.gitea.io/gitea/services/notify" pull_service "code.gitea.io/gitea/services/pull" ) @@ -66,6 +68,10 @@ func DeleteRepository(ctx context.Context, doer *user_model.User, repo *repo_mod return err } + if err := federation_service.DeleteFollowingRepos(ctx, repo.ID); err != nil { + return err + } + return packages_model.UnlinkRepositoryFromAllPackages(ctx, repo.ID) } diff --git a/services/user/user.go b/services/user/user.go index 9dc4f6fe62..4e983eb9f6 100644 --- a/services/user/user.go +++ b/services/user/user.go @@ -1,3 +1,4 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. // Copyright 2021 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT @@ -208,6 +209,13 @@ func DeleteUser(ctx context.Context, u *user_model.User, purge bool) error { return err } } + + // Delete Federated Users + if setting.Federation.Enabled { + if err := user_model.DeleteFederatedUser(ctx, u.ID); err != nil { + return err + } + } } ctx, committer, err := db.TxContext(ctx) diff --git a/templates/repo/settings/options.tmpl b/templates/repo/settings/options.tmpl index 0c68a7a970..52d0847b55 100644 --- a/templates/repo/settings/options.tmpl +++ b/templates/repo/settings/options.tmpl @@ -63,6 +63,28 @@ + {{if FederationEnabled}} +

+ {{ctx.Locale.Tr "repo.settings.federation_settings"}} +

+
+
+ {{.CsrfTokenHtml}} + +
+

{{ctx.Locale.Tr "repo.settings.federation_apapiurl"}}

+

{{.RepositoryAPActorID}}

+
+ + +
+
+ +
+
+
+ {{end}} + {{/* These variables exist to make the logic in the Settings window easier to comprehend and are not used later on. */}} {{$newMirrorsPartiallyEnabled := or (not .DisableNewPullMirrors) (not .DisableNewPushMirrors)}} {{/* .Repository.IsMirror is not always reliable if the repository is not actively acting as a mirror because of errors. */}}