diff --git a/models/forgefed/federationhost_repository.go b/models/forgefed/federationhost_repository.go
index a44b502ba1..c889c1140b 100644
--- a/models/forgefed/federationhost_repository.go
+++ b/models/forgefed/federationhost_repository.go
@@ -16,6 +16,31 @@ func init() {
db.RegisterModel(new(FederationHost))
}
+func CountFederationHosts(ctx context.Context) (int64, error) {
+ return db.GetEngine(ctx).Count(FederationHost{})
+}
+
+func FindFederationHosts(ctx context.Context, opts db.ListOptions) (hosts []*FederationHost, err error) {
+ sess := db.GetEngine(ctx)
+
+ if opts.PageSize > 0 {
+ sess = db.SetSessionPagination(sess, &opts)
+ }
+
+ err = sess.Find(&hosts)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, host := range hosts {
+ if res, err := validation.IsValid(host); !res {
+ return nil, err
+ }
+ }
+
+ return hosts, nil
+}
+
func GetFederationHost(ctx context.Context, ID int64) (*FederationHost, error) {
log.Trace("GetFederationHost: %v", ID)
host := new(FederationHost)
diff --git a/models/user/user_repository.go b/models/user/user_repository.go
index 9692bd7304..1ec0906e4b 100644
--- a/models/user/user_repository.go
+++ b/models/user/user_repository.go
@@ -83,6 +83,57 @@ func FindFederatedUser(ctx context.Context, externalID string, federationHostID
return user, federatedUser, nil
}
+func CountFederatedUsers(ctx context.Context) (int64, error) {
+ return db.GetEngine(ctx).Count(FederatedUser{})
+}
+
+func FindFederatedUsers(ctx context.Context, opts db.ListOptions) (users []*FederatedUser, err error) {
+ sess := db.GetEngine(ctx)
+
+ if opts.PageSize > 0 {
+ sess = db.SetSessionPagination(sess, &opts)
+ }
+
+ err = sess.Find(&users)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, user := range users {
+ if res, err := validation.IsValid(user); !res {
+ return nil, err
+ }
+ }
+
+ return users, err
+}
+
+func CountFederatedUsersByHostID(ctx context.Context, federationHostID int64) (int64, error) {
+ return db.GetEngine(ctx).Where("federation_host_id = ?", federationHostID).Count(FederatedUser{})
+}
+
+func FindFederatedUsersByHostID(ctx context.Context, federationHostID int64, opts db.ListOptions) ([]*FederatedUser, error) {
+ var users []*FederatedUser
+ sess := db.GetEngine(ctx).Where("federation_host_id = ?", federationHostID)
+
+ if opts.PageSize > 0 {
+ sess = db.SetSessionPagination(sess, &opts)
+ }
+
+ err := sess.Find(&users)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, user := range users {
+ if res, err := validation.IsValid(user); !res {
+ return nil, err
+ }
+ }
+
+ return users, nil
+}
+
func GetFederatedUser(ctx context.Context, externalID string, federationHostID int64) (*User, *FederatedUser, error) {
user, federatedUser, err := FindFederatedUser(ctx, externalID, federationHostID)
if err != nil {
diff --git a/modules/setting/ui.go b/modules/setting/ui.go
index 61378e0596..abe902dfc6 100644
--- a/modules/setting/ui.go
+++ b/modules/setting/ui.go
@@ -55,10 +55,12 @@ var UI = struct {
} `ini:"ui.csv"`
Admin struct {
- UserPagingNum int
- RepoPagingNum int
- NoticePagingNum int
- OrgPagingNum int
+ UserPagingNum int
+ RepoPagingNum int
+ NoticePagingNum int
+ OrgPagingNum int
+ FederationHostPagingNum int
+ FederationUserPagingNum int
} `ini:"ui.admin"`
User struct {
RepoPagingNum int
@@ -114,15 +116,19 @@ var UI = struct {
MaxRows: 2500,
},
Admin: struct {
- UserPagingNum int
- RepoPagingNum int
- NoticePagingNum int
- OrgPagingNum int
+ UserPagingNum int
+ RepoPagingNum int
+ NoticePagingNum int
+ OrgPagingNum int
+ FederationHostPagingNum int
+ FederationUserPagingNum int
}{
- UserPagingNum: 50,
- RepoPagingNum: 50,
- NoticePagingNum: 25,
- OrgPagingNum: 50,
+ UserPagingNum: 50,
+ RepoPagingNum: 50,
+ NoticePagingNum: 25,
+ OrgPagingNum: 50,
+ FederationHostPagingNum: 50,
+ FederationUserPagingNum: 50,
},
User: struct {
RepoPagingNum int
diff --git a/options/locale_next/locale_en-US.json b/options/locale_next/locale_en-US.json
index 09b5d3fd69..aab1be7e1b 100644
--- a/options/locale_next/locale_en-US.json
+++ b/options/locale_next/locale_en-US.json
@@ -144,6 +144,37 @@
"keys.ssh.link": "SSH keys",
"keys.gpg.link": "GPG keys",
"keys.verify.token.hint": "The token is only valid for 1 minute. Get a new one if it expired.",
+ "admin.federation.federation": "Federation",
+ "admin.federation.hosts": "Hosts",
+ "admin.federation.hosts.title": "Federation hosts",
+ "admin.federation.hosts.manage_panel": "Manage federation hosts",
+ "admin.federation.hosts.details_panel": "Federation host details",
+ "admin.federation.hosts.show_details": "Show host details",
+ "admin.federation.host.id": "ID",
+ "admin.federation.host.fqdn": "FQDN",
+ "admin.federation.host.schema": "Schema",
+ "admin.federation.host.port": "Port",
+ "admin.federation.host.software_name": "Software",
+ "admin.federation.host.created": "Created",
+ "admin.federation.host.updated": "Updated",
+ "admin.federation.host.latest_activity": "Latest activity",
+ "admin.federation.users": "Users",
+ "admin.federation.users.title": "Federated users",
+ "admin.federation.users.manage_panel": "Manage federated users",
+ "admin.federation.users.show_local_user": "Show local user details",
+ "admin.federation.user.id": "ID",
+ "admin.federation.user.user_id": "Local user ID",
+ "admin.federation.user.external_id": "External user ID",
+ "admin.federation.user.inbox_path": "Inbox path",
+ "admin.config.federation": "Federation configuration",
+ "admin.config.federation.enabled": "Enabled",
+ "admin.config.federation.share_user_statistics": "Share user statistics with other hosts",
+ "admin.config.federation.max_size": "Max allowed response size",
+ "admin.config.federation.signature_enforced": "Require HTTP signatures",
+ "admin.config.federation.signature_algorithms": "Signature algorithms",
+ "admin.config.federation.digest_algorithm": "Signature digest algorithm",
+ "admin.config.federation.get_headers": "Signed GET headers",
+ "admin.config.federation.post_headers": "Signed POST headers",
"admin.config.moderation_config": "Moderation configuration",
"admin.moderation.moderation_reports": "Moderation reports",
"admin.moderation.reports": "Reports",
diff --git a/routers/web/admin/config.go b/routers/web/admin/config.go
index 2d3ea78052..c0eae0846b 100644
--- a/routers/web/admin/config.go
+++ b/routers/web/admin/config.go
@@ -148,6 +148,8 @@ func Config(ctx *context.Context) {
ctx.Data["DbCfg"] = setting.Database
ctx.Data["Webhook"] = setting.Webhook
ctx.Data["Moderation"] = setting.Moderation
+ ctx.Data["Federation"] = setting.Federation
+ ctx.Data["FederationMaxSize"] = setting.Federation.MaxSize / 1024 / 1024 // in MiB
ctx.Data["MailerEnabled"] = false
if setting.MailService != nil {
diff --git a/routers/web/admin/federation_host.go b/routers/web/admin/federation_host.go
new file mode 100644
index 0000000000..278509f217
--- /dev/null
+++ b/routers/web/admin/federation_host.go
@@ -0,0 +1,65 @@
+// Copyright 2026 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package admin
+
+import (
+ "net/http"
+
+ "forgejo.org/models/db"
+ "forgejo.org/models/forgefed"
+ user_model "forgejo.org/models/user"
+ "forgejo.org/modules/base"
+ "forgejo.org/modules/setting"
+ "forgejo.org/services/context"
+)
+
+const (
+ tplFederationHost base.TplName = "admin/federation/host"
+)
+
+func FederationHost(ctx *context.Context) {
+ federationHostID := ctx.ParamsInt64("id")
+ page := ctx.FormInt("page")
+ if page < 1 {
+ page = 1
+ }
+
+ host, err := forgefed.GetFederationHost(ctx, federationHostID)
+ if err != nil {
+ ctx.ServerError("GetFederationHost", err)
+ return
+ }
+
+ users, err := user_model.FindFederatedUsersByHostID(ctx, federationHostID, db.ListOptions{
+ PageSize: setting.UI.Admin.FederationUserPagingNum,
+ Page: int(page),
+ })
+ if err != nil {
+ ctx.ServerError("FindFederatedUsersByHostID", err)
+ return
+ }
+
+ total, err := user_model.CountFederatedUsersByHostID(ctx, federationHostID)
+ if err != nil {
+ ctx.ServerError("CountFederatedUsersByHostID", err)
+ return
+ }
+
+ ctx.Data["Host"] = host
+ ctx.Data["Users"] = users
+ ctx.Data["UsersTotal"] = int(total)
+ ctx.Data["Title"] = ctx.Tr("admin.federation.hosts.details_panel")
+ ctx.Data["PageIsAdminFederationHosts"] = true
+
+ numPages := 0
+ if total > 0 {
+ numPages = (int(total) - 1/setting.UI.Admin.FederationUserPagingNum)
+ }
+
+ pager := context.NewPagination(int(total), setting.UI.Admin.FederationUserPagingNum, page, numPages)
+ pager.SetDefaultParams(ctx)
+ ctx.Data["Page"] = pager
+
+ ctx.HTML(http.StatusOK, tplFederationHost)
+}
diff --git a/routers/web/admin/federation_hosts.go b/routers/web/admin/federation_hosts.go
new file mode 100644
index 0000000000..e710f0aba5
--- /dev/null
+++ b/routers/web/admin/federation_hosts.go
@@ -0,0 +1,58 @@
+// Copyright 2026 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package admin
+
+import (
+ "net/http"
+
+ "forgejo.org/models/db"
+ "forgejo.org/models/forgefed"
+ "forgejo.org/modules/base"
+ "forgejo.org/modules/setting"
+ "forgejo.org/services/context"
+)
+
+const (
+ tplFederationHosts base.TplName = "admin/federation/hosts"
+)
+
+func FederationHosts(ctx *context.Context) {
+ sort := ctx.FormTrim("sort")
+ page := ctx.FormInt("page")
+ if page < 1 {
+ page = 1
+ }
+
+ hosts, err := forgefed.FindFederationHosts(ctx, db.ListOptions{
+ Page: page,
+ PageSize: setting.UI.Admin.FederationHostPagingNum,
+ })
+ if err != nil {
+ ctx.ServerError("GetFederationHosts", err)
+ return
+ }
+
+ total, err := forgefed.CountFederationHosts(ctx)
+ if err != nil {
+ ctx.ServerError("CountFederationHosts", err)
+ return
+ }
+
+ ctx.Data["Title"] = ctx.Tr("admin.federation.hosts.title")
+ ctx.Data["PageIsAdminFederationHosts"] = true
+ ctx.Data["SortType"] = sort
+ ctx.Data["TotalCount"] = int(total)
+ ctx.Data["Hosts"] = hosts
+
+ numPages := 0
+ if total > 0 {
+ numPages = (int(total) - 1/setting.UI.Admin.FederationHostPagingNum)
+ }
+
+ pager := context.NewPagination(int(total), setting.UI.Admin.FederationHostPagingNum, page, numPages)
+ pager.SetDefaultParams(ctx)
+ ctx.Data["Page"] = pager
+
+ ctx.HTML(http.StatusOK, tplFederationHosts)
+}
diff --git a/routers/web/admin/federation_users.go b/routers/web/admin/federation_users.go
new file mode 100644
index 0000000000..098479dcc1
--- /dev/null
+++ b/routers/web/admin/federation_users.go
@@ -0,0 +1,56 @@
+// Copyright 2026 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package admin
+
+import (
+ "net/http"
+
+ "forgejo.org/models/db"
+ user_model "forgejo.org/models/user"
+ "forgejo.org/modules/base"
+ "forgejo.org/modules/setting"
+ "forgejo.org/services/context"
+)
+
+const (
+ tplFederationUsers base.TplName = "admin/federation/users"
+)
+
+func FederationUsers(ctx *context.Context) {
+ page := ctx.FormInt("page")
+ if page < 1 {
+ page = 1
+ }
+
+ users, err := user_model.FindFederatedUsers(ctx, db.ListOptions{
+ Page: page,
+ PageSize: setting.UI.Admin.FederationUserPagingNum,
+ })
+ if err != nil {
+ ctx.ServerError("FindFederatedUsers", err)
+ return
+ }
+
+ total, err := user_model.CountFederatedUsers(ctx)
+ if err != nil {
+ ctx.ServerError("CountFederatedUsers", err)
+ return
+ }
+
+ ctx.Data["Users"] = users
+ ctx.Data["TotalCount"] = int(total)
+ ctx.Data["Title"] = ctx.Tr("admin.federation.users.title")
+ ctx.Data["PageIsAdminFederationUsers"] = true
+
+ numPages := 0
+ if total > 0 {
+ numPages = (int(total) - 1/setting.UI.Admin.FederationUserPagingNum)
+ }
+
+ pager := context.NewPagination(int(total), setting.UI.Admin.FederationUserPagingNum, page, numPages)
+ pager.SetDefaultParams(ctx)
+ ctx.Data["Page"] = pager
+
+ ctx.HTML(http.StatusOK, tplFederationUsers)
+}
diff --git a/routers/web/web.go b/routers/web/web.go
index 7463bf3ea2..766c39fa57 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -837,6 +837,14 @@ func registerRoutes(m *web.Route) {
})
m.Post("/abuse_reports/act", admin.PerformAction)
}
+
+ if setting.Federation.Enabled {
+ m.Group("/federation", func() {
+ m.Get("/hosts", admin.FederationHosts)
+ m.Get("/users", admin.FederationUsers)
+ m.Get("/hosts/{id}", admin.FederationHost)
+ })
+ }
}, adminReq, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled, "EnableModeration", setting.Moderation.Enabled))
// ***** END: Admin *****
diff --git a/services/context/context.go b/services/context/context.go
index f0502b32fe..8ced686aac 100644
--- a/services/context/context.go
+++ b/services/context/context.go
@@ -184,6 +184,7 @@ func Contexter() func(next http.Handler) http.Handler {
ctx.Data["DisableStars"] = setting.Repository.DisableStars
ctx.Data["DisableForks"] = setting.Repository.DisableForks
ctx.Data["EnableActions"] = setting.Actions.Enabled
+ ctx.Data["EnableFederation"] = setting.Federation.Enabled
ctx.Data["UnitWikiGlobalDisabled"] = unit.TypeWiki.UnitGlobalDisabled()
ctx.Data["UnitIssuesGlobalDisabled"] = unit.TypeIssues.UnitGlobalDisabled()
diff --git a/templates/admin/config.tmpl b/templates/admin/config.tmpl
index 0962c6bfc7..c4688922dd 100644
--- a/templates/admin/config.tmpl
+++ b/templates/admin/config.tmpl
@@ -344,6 +344,33 @@
+
+
+
+ - {{ctx.Locale.Tr "admin.config.federation.enabled"}}
+ - {{if .Federation.Enabled}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
+ - {{ctx.Locale.Tr "admin.config.federation.share_user_statistics"}}
+ - {{if .Federation.ShareUserStatistics}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
+ - {{ctx.Locale.Tr "admin.config.federation.max_size"}}
+ - {{.FederationMaxSize}} MiB
+
+
+
+ - {{ctx.Locale.Tr "admin.config.federation.signature_enforced"}}
+ - {{if .Federation.SignatureEnforced}}{{svg "octicon-check"}}{{else}}{{svg "octicon-x"}}{{end}}
+ - {{ctx.Locale.Tr "admin.config.federation.signature_algorithms"}}
+ - {{.Federation.SignatureAlgorithms}}
+ - {{ctx.Locale.Tr "admin.config.federation.digest_algorithm"}}
+ - {{.Federation.DigestAlgorithm}}
+ - {{ctx.Locale.Tr "admin.config.federation.get_headers"}}
+ - {{.Federation.GetHeaders}}
+ - {{ctx.Locale.Tr "admin.config.federation.post_headers"}}
+ - {{.Federation.PostHeaders}}
+
+
+
diff --git a/templates/admin/federation/host.tmpl b/templates/admin/federation/host.tmpl
new file mode 100644
index 0000000000..92fbd1ee2a
--- /dev/null
+++ b/templates/admin/federation/host.tmpl
@@ -0,0 +1,39 @@
+{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin config")}}
+
+ {{if .Host}}
+
+
+
+ - {{ctx.Locale.Tr "admin.federation.host.id"}}
+ - {{.Host.ID}}
+ - {{ctx.Locale.Tr "admin.federation.host.fqdn"}}
+ - {{.Host.HostFqdn}}
+ - {{ctx.Locale.Tr "admin.federation.host.port"}}
+ - {{.Host.HostPort}}
+ - {{ctx.Locale.Tr "admin.federation.host.schema"}}
+ - {{.Host.HostSchema}}
+ - {{ctx.Locale.Tr "admin.federation.host.software_name"}}
+ - {{.Host.NodeInfo.SoftwareName}}
+ - {{ctx.Locale.Tr "admin.federation.host.created"}}
+ - {{DateUtils.AbsoluteShort .Host.Created}}
+ - {{ctx.Locale.Tr "admin.federation.host.updated"}}
+ - {{DateUtils.AbsoluteShort .Host.Updated}}
+ - {{ctx.Locale.Tr "admin.federation.host.latest_activity"}}
+ - {{DateUtils.FullTime .Host.LatestActivity}}
+
+
+ {{end}}
+
+
+ {{template "admin/federation/user_list" .}}
+
+ {{template "base/paginate" .}}
+
+{{template "admin/layout_footer" .}}
diff --git a/templates/admin/federation/hosts.tmpl b/templates/admin/federation/hosts.tmpl
new file mode 100644
index 0000000000..2222873805
--- /dev/null
+++ b/templates/admin/federation/hosts.tmpl
@@ -0,0 +1,56 @@
+{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin user")}}
+
+
+
+
+
+
+ |
+ {{ctx.Locale.Tr "admin.federation.host.id"}}
+ {{SortArrow "id_asc" "id_desc" .SortType true}}
+ |
+
+ {{ctx.Locale.Tr "admin.federation.host.fqdn"}}
+ {{SortArrow "fqdn_asc" "fqdn_desc" .SortType false}}
+ |
+ {{ctx.Locale.Tr "admin.federation.host.schema"}} |
+ {{ctx.Locale.Tr "admin.federation.host.port"}} |
+ {{ctx.Locale.Tr "admin.federation.host.software_name"}} |
+
+ {{ctx.Locale.Tr "admin.federation.host.created"}}
+ {{SortArrow "created_asc" "created_desc" .SortType true}}
+ |
+
+ {{ctx.Locale.Tr "admin.federation.host.latest_activity"}}
+ {{SortArrow "activity_asc" "activity_desc" .SortType true}}
+ |
+ |
+
+
+
+ {{range .Hosts}}
+
+ | {{.ID}} |
+ {{.HostFqdn}} |
+ {{.HostSchema}} |
+ {{.HostPort}} |
+ {{.NodeInfo.SoftwareName}} |
+ {{DateUtils.AbsoluteShort .Created}} |
+ {{DateUtils.FullTime .LatestActivity}} |
+
+
+ |
+
+ {{else}}
+ | {{ctx.Locale.Tr "repo.pulls.no_results"}} |
+ {{end}}
+
+
+
+ {{template "base/paginate" .}}
+
+{{template "admin/layout_footer" .}}
diff --git a/templates/admin/federation/user_list.tmpl b/templates/admin/federation/user_list.tmpl
new file mode 100644
index 0000000000..18ff34b848
--- /dev/null
+++ b/templates/admin/federation/user_list.tmpl
@@ -0,0 +1,28 @@
+
+
+
+ | {{ctx.Locale.Tr "admin.federation.user.id"}} |
+ {{ctx.Locale.Tr "admin.federation.user.user_id"}} |
+ {{ctx.Locale.Tr "admin.federation.user.external_id"}} |
+ {{ctx.Locale.Tr "admin.federation.user.inbox_path"}} |
+ |
+
+
+
+ {{range .Users}}
+
+ | {{.ID}} |
+ {{.UserID}} |
+ {{.ExternalID}} |
+ {{.InboxPath}} |
+
+
+ |
+
+ {{else}}
+ | {{ctx.Locale.Tr "repo.pulls.no_results"}} |
+ {{end}}
+
+
diff --git a/templates/admin/federation/users.tmpl b/templates/admin/federation/users.tmpl
new file mode 100644
index 0000000000..8ddcff3dad
--- /dev/null
+++ b/templates/admin/federation/users.tmpl
@@ -0,0 +1,11 @@
+{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin user")}}
+
+
+
+ {{template "admin/federation/user_list" .}}
+
+ {{template "base/paginate" .}}
+
+{{template "admin/layout_footer" .}}
diff --git a/templates/admin/navbar.tmpl b/templates/admin/navbar.tmpl
index 7ca8538bce..fa1500341d 100644
--- a/templates/admin/navbar.tmpl
+++ b/templates/admin/navbar.tmpl
@@ -77,6 +77,19 @@
{{end}}
+ {{if .EnableFederation}}
+
+ {{ctx.Locale.Tr "admin.federation.federation"}}
+
+
+ {{end}}
{{ctx.Locale.Tr "admin.config"}}