diff --git a/models/user/setting.go b/models/user/setting.go index c65afae76c84f..21b8aa8f7767e 100644 --- a/models/user/setting.go +++ b/models/user/setting.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/modules/util" "xorm.io/builder" + "xorm.io/xorm" ) // Setting is a key value store of user settings @@ -211,3 +212,17 @@ func upsertUserSettingValue(ctx context.Context, userID int64, key, value string return err }) } + +// BuildSignupIPQuery builds a query to find users by their signup IP addresses +func BuildSignupIPQuery(ctx context.Context, keyword string) *xorm.Session { + query := db.GetEngine(ctx). + Table("user_setting"). + Join("INNER", "user", "user.id = user_setting.user_id"). + Where("user_setting.setting_key = ?", SignupIP) + + if len(keyword) > 0 { + query = query.And("(user.lower_name LIKE ? OR user.full_name LIKE ? OR user_setting.setting_value LIKE ?)", + "%"+strings.ToLower(keyword)+"%", "%"+keyword+"%", "%"+keyword+"%") + } + return query +} diff --git a/modules/util/network.go b/modules/util/network.go new file mode 100644 index 0000000000000..e918f4a7ddfe6 --- /dev/null +++ b/modules/util/network.go @@ -0,0 +1,32 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package util + +import ( + "strings" +) + +// TrimPortFromIP removes the client port from an IP address +// Handles both IPv4 and IPv6 addresses with ports +func TrimPortFromIP(ip string) string { + // Handle IPv6 with brackets: [IPv6]:port + if strings.HasPrefix(ip, "[") { + // If there's no port, return as is + if !strings.Contains(ip, "]:") { + return ip + } + // Remove the port part after ]: + return strings.Split(ip, "]:")[0] + "]" + } + + // Count colons to differentiate between IPv4 and IPv6 + colonCount := strings.Count(ip, ":") + + // Handle IPv4 with port (single colon) + if colonCount == 1 { + return strings.Split(ip, ":")[0] + } + + return ip +} diff --git a/modules/util/network_test.go b/modules/util/network_test.go new file mode 100644 index 0000000000000..e254c9d23a015 --- /dev/null +++ b/modules/util/network_test.go @@ -0,0 +1,66 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package util + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTrimPortFromIP(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "IPv4 without port", + input: "192.168.1.1", + expected: "192.168.1.1", + }, + { + name: "IPv4 with port", + input: "192.168.1.1:8080", + expected: "192.168.1.1", + }, + { + name: "IPv6 without port", + input: "2001:db8::1", + expected: "2001:db8::1", + }, + { + name: "IPv6 with brackets, without port", + input: "[2001:db8::1]", + expected: "[2001:db8::1]", + }, + { + name: "IPv6 with brackets and port", + input: "[2001:db8::1]:8080", + expected: "[2001:db8::1]", + }, + { + name: "localhost with port", + input: "localhost:8080", + expected: "localhost", + }, + { + name: "Empty string", + input: "", + expected: "", + }, + { + name: "Not an IP address", + input: "abc123", + expected: "abc123", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := TrimPortFromIP(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 54089be24a1c2..b342e66c4b599 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -3088,6 +3088,16 @@ users.list_status_filter.is_2fa_enabled = 2FA Enabled users.list_status_filter.not_2fa_enabled = 2FA Disabled users.details = User Details +ips.ip = IP Address +ips.user_agent = User Agent +ips.ip_manage_panel = Signup IP Management +ips.signup_metadata = Signup Metadata +ips.not_available = Signup metadata not available +ips.filter_sort.ip = Sort by IP (asc) +ips.filter_sort.ip_reverse = Sort by IP (desc) +ips.filter_sort.name = Sort by Username (asc) +ips.filter_sort.name_reverse = Sort by Username (desc) + emails.email_manage_panel = User Email Management emails.primary = Primary emails.activated = Activated diff --git a/routers/web/admin/ips.go b/routers/web/admin/ips.go new file mode 100644 index 0000000000000..3ba605630388c --- /dev/null +++ b/routers/web/admin/ips.go @@ -0,0 +1,104 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package admin + +import ( + "net/http" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/util" + "code.gitea.io/gitea/services/context" +) + +const ( + tplIPs templates.TplName = "admin/ips/list" +) + +// IPs show all user signup IPs +func IPs(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("admin.ips.ip") + ctx.Data["PageIsAdminIPs"] = true + ctx.Data["RecordUserSignupMetadata"] = setting.RecordUserSignupMetadata + + // If record user signup metadata is disabled, don't show the page + if !setting.RecordUserSignupMetadata { + ctx.Redirect(setting.AppSubURL + "/-/admin") + return + } + + page := ctx.FormInt("page") + if page <= 1 { + page = 1 + } + + // Define the user IP result struct + type UserIPResult struct { + UID int64 + Name string + FullName string + IP string + } + + var ( + userIPs []UserIPResult + count int64 + err error + orderBy string + keyword = ctx.FormTrim("q") + sortType = ctx.FormString("sort") + ) + + ctx.Data["SortType"] = sortType + switch sortType { + case "ip": + orderBy = "user_setting.setting_value ASC, user.id ASC" + case "reverseip": + orderBy = "user_setting.setting_value DESC, user.id DESC" + case "username": + orderBy = "user.lower_name ASC, user.id ASC" + case "reverseusername": + orderBy = "user.lower_name DESC, user.id DESC" + default: + ctx.Data["SortType"] = "ip" + orderBy = "user_setting.setting_value ASC, user.id ASC" + } + + // Get the count and user IPs for pagination + query := user_model.BuildSignupIPQuery(ctx, keyword) + + count, err = query.Count(new(user_model.Setting)) + if err != nil { + ctx.ServerError("Count", err) + return + } + + err = user_model.BuildSignupIPQuery(ctx, keyword). + Select("user.id as uid, user.name, user.full_name, user_setting.setting_value as ip"). + OrderBy(orderBy). + Limit(setting.UI.Admin.UserPagingNum, (page-1)*setting.UI.Admin.UserPagingNum). + Find(&userIPs) + if err != nil { + ctx.ServerError("Find", err) + return + } + + for i := range userIPs { + // Trim the port from the IP + // FIXME: Maybe have a different helper for this? + userIPs[i].IP = util.TrimPortFromIP(userIPs[i].IP) + } + + ctx.Data["UserIPs"] = userIPs + ctx.Data["Total"] = count + ctx.Data["Keyword"] = keyword + + // Setup pagination + pager := context.NewPagination(int(count), setting.UI.Admin.UserPagingNum, page, 5) + pager.AddParamFromRequest(ctx.Req) + ctx.Data["Page"] = pager + + ctx.HTML(http.StatusOK, tplIPs) +} diff --git a/routers/web/admin/users.go b/routers/web/admin/users.go index e42cbb316c3e2..5ba92a0496e30 100644 --- a/routers/web/admin/users.go +++ b/routers/web/admin/users.go @@ -22,6 +22,7 @@ import ( "code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/web/explore" user_setting "code.gitea.io/gitea/routers/web/user/setting" @@ -262,6 +263,7 @@ func ViewUser(ctx *context.Context) { ctx.Data["DisableRegularOrgCreation"] = setting.Admin.DisableRegularOrgCreation ctx.Data["DisableMigrations"] = setting.Repository.DisableMigrations ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice() + ctx.Data["ShowUserSignupMetadata"] = setting.RecordUserSignupMetadata u := prepareUserInfo(ctx) if ctx.Written() { @@ -291,6 +293,25 @@ func ViewUser(ctx *context.Context) { ctx.Data["Emails"] = emails ctx.Data["EmailsTotal"] = len(emails) + // If record user signup metadata is enabled, get the user's signup IP and user agent + if setting.RecordUserSignupMetadata { + signupIP, err := user_model.GetUserSetting(ctx, u.ID, user_model.SignupIP) + if err == nil && len(signupIP) > 0 { + ctx.Data["HasSignupIP"] = true + ctx.Data["SignupIP"] = util.TrimPortFromIP(signupIP) + } else { + ctx.Data["HasSignupIP"] = false + } + + signupUserAgent, err := user_model.GetUserSetting(ctx, u.ID, user_model.SignupUserAgent) + if err == nil && len(signupUserAgent) > 0 { + ctx.Data["HasSignupUserAgent"] = true + ctx.Data["SignupUserAgent"] = signupUserAgent + } else { + ctx.Data["HasSignupUserAgent"] = false + } + } + orgs, err := db.Find[org_model.Organization](ctx, org_model.FindOrgOptions{ ListOptions: db.ListOptionsAll, UserID: u.ID, diff --git a/routers/web/web.go b/routers/web/web.go index f28dc6baa4d62..dce8114ba250a 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -755,6 +755,10 @@ func registerWebRoutes(m *web.Router) { m.Post("/delete", admin.DeleteEmail) }) + m.Group("/ips", func() { + m.Get("", admin.IPs) + }) + m.Group("/orgs", func() { m.Get("", admin.Organizations) }) @@ -814,7 +818,7 @@ func registerWebRoutes(m *web.Router) { addSettingsRunnersRoutes() addSettingsVariablesRoutes() }) - }, adminReq, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled)) + }, adminReq, ctxDataSet("RecordUserSignupMetadata", setting.RecordUserSignupMetadata, "EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled)) // ***** END: Admin ***** m.Group("", func() { diff --git a/templates/admin/ips/list.tmpl b/templates/admin/ips/list.tmpl new file mode 100644 index 0000000000000..6124b59950f07 --- /dev/null +++ b/templates/admin/ips/list.tmpl @@ -0,0 +1,58 @@ +{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin user")}} +
+

+ {{ctx.Locale.Tr "admin.ips.ip_manage_panel"}} ({{ctx.Locale.Tr "admin.total" .Total}}) +

+
+ +
+
+ + + + + + + + + + {{range .UserIPs}} + + + + + + {{else}} + + {{end}} + +
+ {{ctx.Locale.Tr "admin.users.name"}} + {{SortArrow "username" "reverseusername" $.SortType false}} + {{ctx.Locale.Tr "admin.users.full_name"}} + {{ctx.Locale.Tr "admin.ips.ip"}} + {{SortArrow "ip" "reverseip" $.SortType true}} +
{{.Name}}{{.FullName}}{{.IP}}
{{ctx.Locale.Tr "no_results_found"}}
+
+ + {{template "base/paginate" .}} +
+ +{{template "admin/layout_footer" .}} diff --git a/templates/admin/navbar.tmpl b/templates/admin/navbar.tmpl index 72584ec799cc3..1b89a965ea5d4 100644 --- a/templates/admin/navbar.tmpl +++ b/templates/admin/navbar.tmpl @@ -13,7 +13,7 @@ -
+
{{ctx.Locale.Tr "admin.identity_access"}}
diff --git a/templates/admin/user/view.tmpl b/templates/admin/user/view.tmpl index 31616ffbf969a..d5bbd81dc34ea 100644 --- a/templates/admin/user/view.tmpl +++ b/templates/admin/user/view.tmpl @@ -22,6 +22,14 @@ + {{if .ShowUserSignupMetadata}} +

+ {{ctx.Locale.Tr "admin.ips.signup_metadata"}} +

+
+ {{template "admin/user/view_ip" .}} +
+ {{end}}

{{ctx.Locale.Tr "admin.repositories"}} ({{ctx.Locale.Tr "admin.total" .ReposTotal}})

diff --git a/templates/admin/user/view_ip.tmpl b/templates/admin/user/view_ip.tmpl new file mode 100644 index 0000000000000..b8b163a3040c7 --- /dev/null +++ b/templates/admin/user/view_ip.tmpl @@ -0,0 +1,18 @@ +{{if .HasSignupIP}} +
+
+
+
+ {{ctx.Locale.Tr "admin.ips.ip"}}: {{.SignupIP}} +
+ {{if .HasSignupUserAgent}} +
+ {{ctx.Locale.Tr "admin.ips.user_agent"}}: {{.SignupUserAgent}} +
+ {{end}} +
+
+
+{{else}} +
{{ctx.Locale.Tr "admin.ips.not_available"}}
+{{end}}