From 82cb9e0203777a28a792cb29a709f0e62ae1b778 Mon Sep 17 00:00:00 2001
From: Michael Jerger <michael.jerger@meissa-gmbh.de>
Date: Fri, 24 May 2024 13:28:15 +0200
Subject: [PATCH 1/4] ui for adding following repos

---
 models/repo/following_repo.go             | 39 +++++++++++++++
 models/repo/following_repo_test.go        | 31 ++++++++++++
 models/repo/repo.go                       |  6 +++
 models/repo/repo_repository.go            | 60 +++++++++++++++++++++++
 models/repo/repo_test.go                  | 10 ++++
 modules/templates/helper.go               |  4 ++
 options/locale/locale_de-DE.ini           |  5 ++
 options/locale/locale_en-US.ini           |  6 +++
 routers/web/repo/setting/setting.go       | 37 ++++++++++++++
 services/context/repo.go                  | 17 +++++++
 services/federation/federation_service.go | 31 ++++++++++++
 services/forms/repo_form.go               |  2 +
 services/repository/repository.go         |  6 +++
 services/user/user.go                     |  8 +++
 templates/repo/settings/options.tmpl      | 22 +++++++++
 15 files changed, 284 insertions(+)
 create mode 100644 models/repo/following_repo.go
 create mode 100644 models/repo/following_repo_test.go
 create mode 100644 models/repo/repo_repository.go

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 @@
 			</form>
 		</div>
 
+		{{if FederationEnabled}}
+			<h4 class="ui top attached header">
+				{{ctx.Locale.Tr "repo.settings.federation_settings"}}
+			</h4>
+			<div class="ui attached segment">
+				<form class="ui form" method="post">
+					{{.CsrfTokenHtml}}
+					<input type="hidden" name="action" value="federation">
+					<div class="field {{if .Err_FollowingRepos}}error{{end}}">
+						<p>{{ctx.Locale.Tr "repo.settings.federation_apapiurl"}}</p>
+						<p><b>{{.RepositoryAPActorID}}</b></p>
+						<div class="divider"></div>
+						<label for="following_repos">{{ctx.Locale.Tr "repo.settings.federation_following_repos"}}</label>
+						<input id="following_repos" name="federation_repos" value="{{.FollowingRepos}}">
+					</div>
+					<div class="field">
+						<button class="ui primary button">{{ctx.Locale.Tr "repo.settings.update_settings"}}</button>
+					</div>
+				</form>
+			</div>
+		{{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. */}}

From 9705d42e02300f8cddd6227bb0025094ee723382 Mon Sep 17 00:00:00 2001
From: Michael Jerger <michael.jerger@meissa-gmbh.de>
Date: Fri, 24 May 2024 13:38:43 +0200
Subject: [PATCH 2/4] lint

---
 .deadcode-out                             | 1 -
 services/federation/federation_service.go | 1 -
 2 files changed, 2 deletions(-)

diff --git a/.deadcode-out b/.deadcode-out
index 51429164ae..ac62e77ba7 100644
--- a/.deadcode-out
+++ b/.deadcode-out
@@ -136,7 +136,6 @@ package "code.gitea.io/gitea/models/user"
 	func DeleteUserSetting
 	func GetUserEmailsByNames
 	func GetUserNamesByIDs
-	func DeleteFederatedUser
 
 package "code.gitea.io/gitea/modules/activitypub"
 	func (*Client).Post
diff --git a/services/federation/federation_service.go b/services/federation/federation_service.go
index 9f99c04e9a..1c99f784bc 100644
--- a/services/federation/federation_service.go
+++ b/services/federation/federation_service.go
@@ -242,4 +242,3 @@ func StoreFollowingRepoList(ctx context.Context, localRepoID int64, followingRep
 func DeleteFollowingRepos(ctx context.Context, localRepoID int64) error {
 	return repo.StoreFollowingRepos(ctx, localRepoID, []*repo.FollowingRepo{})
 }
-

From 2f7f1aab8a3060f51825426cf2f93ede04e3d176 Mon Sep 17 00:00:00 2001
From: Michael Jerger <michael.jerger@meissa-gmbh.de>
Date: Wed, 29 May 2024 18:31:06 +0200
Subject: [PATCH 3/4] fix review

---
 models/forgejo_migrations/migrate.go    |  2 +
 models/forgejo_migrations/v18.go        | 18 ++++++++
 modules/forgefed/actor.go               | 10 +----
 options/locale/locale_de-DE.ini         |  5 ---
 routers/web/repo/setting/setting.go     | 13 +++---
 services/forms/repo_form.go             |  2 +-
 tests/integration/repo_settings_test.go | 58 +++++++++++++++++++++++++
 7 files changed, 86 insertions(+), 22 deletions(-)
 create mode 100644 models/forgejo_migrations/v18.go

diff --git a/models/forgejo_migrations/migrate.go b/models/forgejo_migrations/migrate.go
index 85229994b4..78c13f33a0 100644
--- a/models/forgejo_migrations/migrate.go
+++ b/models/forgejo_migrations/migrate.go
@@ -72,6 +72,8 @@ var migrations = []*Migration{
 	NewMigration("Create the `federated_user` table", CreateFederatedUserTable),
 	// v17 -> v18
 	NewMigration("Add `normalized_federated_uri` column to `user` table", AddNormalizedFederatedURIToUser),
+	// v18 -> v19
+	NewMigration("Create the `following_repo` table", CreateFollowingRepoTable),
 }
 
 // GetCurrentDBVersion returns the current Forgejo database version.
diff --git a/models/forgejo_migrations/v18.go b/models/forgejo_migrations/v18.go
new file mode 100644
index 0000000000..afccfbfe15
--- /dev/null
+++ b/models/forgejo_migrations/v18.go
@@ -0,0 +1,18 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package forgejo_migrations //nolint:revive
+
+import "xorm.io/xorm"
+
+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 CreateFollowingRepoTable(x *xorm.Engine) error {
+	return x.Sync(new(FederatedUser))
+}
diff --git a/modules/forgefed/actor.go b/modules/forgefed/actor.go
index d3cae20dec..0ef46185d1 100644
--- a/modules/forgefed/actor.go
+++ b/modules/forgefed/actor.go
@@ -8,7 +8,6 @@ import (
 	"net/url"
 	"strings"
 
-	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/validation"
 
 	ap "github.com/go-ap/activitypub"
@@ -71,10 +70,6 @@ type PersonID struct {
 
 // 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
@@ -126,16 +121,13 @@ type RepositoryID struct {
 
 // 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
+	// validate Person specific
 	repoID := RepositoryID{result}
 	if valid, err := validation.IsValid(repoID); !valid {
 		return RepositoryID{}, err
diff --git a/options/locale/locale_de-DE.ini b/options/locale/locale_de-DE.ini
index 59eb11240d..4c20f875ba 100644
--- a/options/locale/locale_de-DE.ini
+++ b/options/locale/locale_de-DE.ini
@@ -1131,7 +1131,6 @@ 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
@@ -2061,10 +2060,6 @@ 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/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go
index b29ab3c4a9..66e96b9961 100644
--- a/routers/web/repo/setting/setting.go
+++ b/routers/web/repo/setting/setting.go
@@ -391,22 +391,21 @@ func SettingsPost(ctx *context.Context) {
 			ctx.Flash.Info(ctx.Tr("repo.settings.federation_not_enabled"))
 			return
 		}
-		// ToDo: Rename to followingRepos
-		federationRepos := strings.TrimSpace(form.FederationRepos)
-		federationRepos = strings.TrimSuffix(federationRepos, ";")
+		followingRepos := strings.TrimSpace(form.FollowingRepos)
+		followingRepos = strings.TrimSuffix(followingRepos, ";")
 
 		maxFollowingRepoStrLength := 2048
-		errs := validation.ValidateMaxLen(federationRepos, maxFollowingRepoStrLength, "federationRepos")
+		errs := validation.ValidateMaxLen(followingRepos, maxFollowingRepoStrLength, "federationRepos")
 		if len(errs) > 0 {
-			ctx.Data["ERR_FederationRepos"] = true
+			ctx.Data["ERR_FollowingRepos"] = 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, ";")
+		if followingRepos != "" {
+			federationRepoSplit = strings.Split(followingRepos, ";")
 		}
 		for idx, repo := range federationRepoSplit {
 			federationRepoSplit[idx] = strings.TrimSpace(repo)
diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go
index 1bc06b1b9a..e826d179ed 100644
--- a/services/forms/repo_form.go
+++ b/services/forms/repo_form.go
@@ -114,7 +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
+	FollowingRepos         string
 	Interval               string
 	MirrorAddress          string
 	MirrorUsername         string
diff --git a/tests/integration/repo_settings_test.go b/tests/integration/repo_settings_test.go
index de86cba77a..584c1024de 100644
--- a/tests/integration/repo_settings_test.go
+++ b/tests/integration/repo_settings_test.go
@@ -6,9 +6,11 @@ package integration
 import (
 	"fmt"
 	"net/http"
+	"net/http/httptest"
 	"testing"
 
 	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/models/forgefed"
 	git_model "code.gitea.io/gitea/models/git"
 	repo_model "code.gitea.io/gitea/models/repo"
 	unit_model "code.gitea.io/gitea/models/unit"
@@ -263,3 +265,59 @@ func TestProtectedBranch(t *testing.T) {
 		unittest.AssertCount(t, &git_model.ProtectedBranch{RuleName: "master", RepoID: repo.ID}, 1)
 	})
 }
+
+func TestRepoFollowing(t *testing.T) {
+	setting.Federation.Enabled = true
+	defer tests.PrepareTestEnv(t)()
+	defer func() {
+		setting.Federation.Enabled = false
+	}()
+
+	federatedRoutes := http.NewServeMux()
+	federatedRoutes.HandleFunc("/.well-known/nodeinfo",
+		func(res http.ResponseWriter, req *http.Request) {
+			// curl -H "Accept: application/json" https://federated-repo.prod.meissa.de/.well-known/nodeinfo
+			responseBody := fmt.Sprintf(`{"links":[{"href":"http://%s/api/v1/nodeinfo","rel":"http://nodeinfo.diaspora.software/ns/schema/2.1"}]}`, req.Host)
+			t.Logf("response: %s", responseBody)
+			// TODO: as soon as content-type will become important:  content-type: application/json;charset=utf-8
+			fmt.Fprint(res, responseBody)
+		})
+	federatedRoutes.HandleFunc("/api/v1/nodeinfo",
+		func(res http.ResponseWriter, req *http.Request) {
+			// curl -H "Accept: application/json" https://federated-repo.prod.meissa.de/api/v1/nodeinfo
+			responseBody := fmt.Sprintf(`{"version":"2.1","software":{"name":"forgejo","version":"1.20.0+dev-3183-g976d79044",` +
+				`"repository":"https://codeberg.org/forgejo/forgejo.git","homepage":"https://forgejo.org/"},` +
+				`"protocols":["activitypub"],"services":{"inbound":[],"outbound":["rss2.0"]},` +
+				`"openRegistrations":true,"usage":{"users":{"total":14,"activeHalfyear":2}},"metadata":{}}`)
+			fmt.Fprint(res, responseBody)
+		})
+	federatedRoutes.HandleFunc("/",
+		func(res http.ResponseWriter, req *http.Request) {
+			t.Errorf("Unhandled request: %q", req.URL.EscapedPath())
+		})
+	federatedSrv := httptest.NewServer(federatedRoutes)
+	defer federatedSrv.Close()
+
+	user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+	repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, OwnerID: user.ID})
+	session := loginUser(t, user.Name)
+
+	t.Run("Add a following repo", func(t *testing.T) {
+		defer tests.PrintCurrentTest(t)()
+		link := fmt.Sprintf("/%s/settings", repo.FullName())
+
+		req := NewRequestWithValues(t, "POST", link, map[string]string{
+			"_csrf":           GetCSRF(t, session, link),
+			"action":          "federation",
+			"following_repos": fmt.Sprintf("%s/api/v1/activitypub/repository-id/1", federatedSrv.URL),
+		})
+		session.MakeRequest(t, req, http.StatusSeeOther)
+
+		// Verify it was added.
+		federationHost := unittest.AssertExistsAndLoadBean(t, &forgefed.FederationHost{HostFqdn: "127.0.0.1"})
+		unittest.AssertExistsAndLoadBean(t, &repo_model.FollowingRepo{
+			ExternalID:       "1",
+			FederationHostID: federationHost.ID,
+		})
+	})
+}

From 8aade372cbcd7c8d51a87b92fd7d44bb9ed52c9c Mon Sep 17 00:00:00 2001
From: Michael Jerger <michael.jerger@meissa-gmbh.de>
Date: Fri, 31 May 2024 16:28:26 +0200
Subject: [PATCH 4/4] add a release note

---
 release-notes/8.0.0/feat/3886.md | 1 +
 1 file changed, 1 insertion(+)
 create mode 100644 release-notes/8.0.0/feat/3886.md

diff --git a/release-notes/8.0.0/feat/3886.md b/release-notes/8.0.0/feat/3886.md
new file mode 100644
index 0000000000..262f5ed8f6
--- /dev/null
+++ b/release-notes/8.0.0/feat/3886.md
@@ -0,0 +1 @@
+For federated-star we introduce a new repository setting to define following repositories. That is a workaround till we find a better way to express repository federation.