Skip to content

Commit 5d716fb

Browse files
Display Usersign metadata in admin dashboard
1 parent a4df01b commit 5d716fb

File tree

8 files changed

+290
-2
lines changed

8 files changed

+290
-2
lines changed

options/locale/locale_en-US.ini

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3075,6 +3075,17 @@ users.list_status_filter.is_2fa_enabled = 2FA Enabled
30753075
users.list_status_filter.not_2fa_enabled = 2FA Disabled
30763076
users.details = User Details
30773077
3078+
ips = Signup IPs
3079+
ips.ip = IP Address
3080+
ips.user_agent = User Agent
3081+
ips.ip_manage_panel = Signup IP Management
3082+
ips.signup_metadata = Signup Metadata
3083+
ips.not_available = Signup metadata not available
3084+
ips.filter_sort.ip = Sort by IP (asc)
3085+
ips.filter_sort.ip_reverse = Sort by IP (desc)
3086+
ips.filter_sort.name = Sort by Username (asc)
3087+
ips.filter_sort.name_reverse = Sort by Username (desc)
3088+
30783089
emails.email_manage_panel = User Email Management
30793090
emails.primary = Primary
30803091
emails.activated = Activated

routers/web/admin/ips.go

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package admin
5+
6+
import (
7+
"net/http"
8+
"strings"
9+
10+
"code.gitea.io/gitea/models/db"
11+
user_model "code.gitea.io/gitea/models/user"
12+
"code.gitea.io/gitea/modules/setting"
13+
"code.gitea.io/gitea/modules/templates"
14+
"code.gitea.io/gitea/services/context"
15+
)
16+
17+
const (
18+
tplIPs templates.TplName = "admin/ips/list"
19+
)
20+
21+
// trimPortFromIP removes the client port from an IP address
22+
// Handles both IPv4 and IPv6 addresses with ports
23+
func trimPortFromIP(ip string) string {
24+
// Handle IPv6 with brackets: [IPv6]:port
25+
if strings.HasPrefix(ip, "[") {
26+
// If there's no port, return as is
27+
if !strings.Contains(ip, "]:") {
28+
return ip
29+
}
30+
// Remove the port part after ]:
31+
return strings.Split(ip, "]:")[0] + "]"
32+
}
33+
34+
// Count colons to differentiate between IPv4 and IPv6
35+
colonCount := strings.Count(ip, ":")
36+
37+
// Handle IPv4 with port (single colon)
38+
if colonCount == 1 {
39+
return strings.Split(ip, ":")[0]
40+
}
41+
42+
return ip
43+
}
44+
45+
// IPs show all user signup IPs
46+
func IPs(ctx *context.Context) {
47+
ctx.Data["Title"] = ctx.Tr("admin.ips")
48+
ctx.Data["PageIsAdminIPs"] = true
49+
ctx.Data["RecordUserSignupMetadata"] = setting.RecordUserSignupMetadata
50+
51+
// If record user signup metadata is disabled, don't show the page
52+
if !setting.RecordUserSignupMetadata {
53+
ctx.Redirect(setting.AppSubURL + "/-/admin")
54+
return
55+
}
56+
57+
page := ctx.FormInt("page")
58+
if page <= 1 {
59+
page = 1
60+
}
61+
62+
// Define the user IP result struct
63+
type UserIPResult struct {
64+
UID int64
65+
Name string
66+
FullName string
67+
IP string
68+
UserAgent string
69+
}
70+
71+
var (
72+
userIPs []UserIPResult
73+
count int64
74+
err error
75+
orderBy string
76+
keyword = ctx.FormTrim("q")
77+
sortType = ctx.FormString("sort")
78+
)
79+
80+
ctx.Data["SortType"] = sortType
81+
switch sortType {
82+
case "ip":
83+
orderBy = "user_setting.setting_value ASC, user.id ASC"
84+
case "reverseip":
85+
orderBy = "user_setting.setting_value DESC, user.id DESC"
86+
case "username":
87+
orderBy = "user.lower_name ASC, user.id ASC"
88+
case "reverseusername":
89+
orderBy = "user.lower_name DESC, user.id DESC"
90+
default:
91+
ctx.Data["SortType"] = "ip"
92+
sortType = "ip"
93+
orderBy = "user_setting.setting_value ASC, user.id ASC"
94+
}
95+
96+
// Get the count and user IPs for pagination
97+
if len(keyword) == 0 {
98+
// Simple count without keyword
99+
count, err = db.GetEngine(ctx).
100+
Join("INNER", "user", "user.id = user_setting.user_id").
101+
Where("user_setting.setting_key = ?", user_model.SignupIP).
102+
Count(new(user_model.Setting))
103+
if err != nil {
104+
ctx.ServerError("Count", err)
105+
return
106+
}
107+
108+
// Get the user IPs
109+
err = db.GetEngine(ctx).
110+
Table("user_setting").
111+
Join("INNER", "user", "user.id = user_setting.user_id").
112+
Where("user_setting.setting_key = ?", user_model.SignupIP).
113+
Select("user.id as uid, user.name, user.full_name, user_setting.setting_value as ip, '' as user_agent").
114+
OrderBy(orderBy).
115+
Limit(setting.UI.Admin.UserPagingNum, (page-1)*setting.UI.Admin.UserPagingNum).
116+
Find(&userIPs)
117+
if err != nil {
118+
ctx.ServerError("Find", err)
119+
return
120+
}
121+
} else {
122+
// Count with keyword filter
123+
count, err = db.GetEngine(ctx).
124+
Join("INNER", "user", "user.id = user_setting.user_id").
125+
Where("user_setting.setting_key = ?", user_model.SignupIP).
126+
And("(user.lower_name LIKE ? OR user.full_name LIKE ? OR user_setting.setting_value LIKE ?)",
127+
"%"+strings.ToLower(keyword)+"%", "%"+keyword+"%", "%"+keyword+"%").
128+
Count(new(user_model.Setting))
129+
if err != nil {
130+
ctx.ServerError("Count", err)
131+
return
132+
}
133+
134+
// Get the user IPs with keyword filter
135+
err = db.GetEngine(ctx).
136+
Table("user_setting").
137+
Join("INNER", "user", "user.id = user_setting.user_id").
138+
Where("user_setting.setting_key = ?", user_model.SignupIP).
139+
And("(user.lower_name LIKE ? OR user.full_name LIKE ? OR user_setting.setting_value LIKE ?)",
140+
"%"+strings.ToLower(keyword)+"%", "%"+keyword+"%", "%"+keyword+"%").
141+
Select("user.id as uid, user.name, user.full_name, user_setting.setting_value as ip, '' as user_agent").
142+
OrderBy(orderBy).
143+
Limit(setting.UI.Admin.UserPagingNum, (page-1)*setting.UI.Admin.UserPagingNum).
144+
Find(&userIPs)
145+
if err != nil {
146+
ctx.ServerError("Find", err)
147+
return
148+
}
149+
}
150+
for i := range userIPs {
151+
// Trim the port from the IP
152+
// FIXME: Maybe have a different helper for this?
153+
userIPs[i].IP = trimPortFromIP(userIPs[i].IP)
154+
}
155+
156+
ctx.Data["UserIPs"] = userIPs
157+
ctx.Data["Total"] = count
158+
ctx.Data["Keyword"] = keyword
159+
160+
// Setup pagination
161+
ctx.Data["Page"] = context.NewPagination(int(count), setting.UI.Admin.UserPagingNum, page, 5)
162+
163+
ctx.HTML(http.StatusOK, tplIPs)
164+
}

routers/web/admin/users.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,7 @@ func ViewUser(ctx *context.Context) {
263263
ctx.Data["DisableRegularOrgCreation"] = setting.Admin.DisableRegularOrgCreation
264264
ctx.Data["DisableMigrations"] = setting.Repository.DisableMigrations
265265
ctx.Data["AllowedUserVisibilityModes"] = setting.Service.AllowedUserVisibilityModesSlice.ToVisibleTypeSlice()
266+
ctx.Data["ShowUserSignupMetadata"] = setting.RecordUserSignupMetadata
266267

267268
u := prepareUserInfo(ctx)
268269
if ctx.Written() {
@@ -292,6 +293,25 @@ func ViewUser(ctx *context.Context) {
292293
ctx.Data["Emails"] = emails
293294
ctx.Data["EmailsTotal"] = len(emails)
294295

296+
// If record user signup metadata is enabled, get the user's signup IP and user agent
297+
if setting.RecordUserSignupMetadata {
298+
signupIP, err := user_model.GetUserSetting(ctx, u.ID, user_model.SignupIP)
299+
if err == nil && len(signupIP) > 0 {
300+
ctx.Data["HasSignupIP"] = true
301+
ctx.Data["SignupIP"] = trimPortFromIP(signupIP)
302+
} else {
303+
ctx.Data["HasSignupIP"] = false
304+
}
305+
306+
signupUserAgent, err := user_model.GetUserSetting(ctx, u.ID, user_model.SignupUserAgent)
307+
if err == nil && len(signupUserAgent) > 0 {
308+
ctx.Data["HasSignupUserAgent"] = true
309+
ctx.Data["SignupUserAgent"] = signupUserAgent
310+
} else {
311+
ctx.Data["HasSignupUserAgent"] = false
312+
}
313+
}
314+
295315
orgs, err := db.Find[org_model.Organization](ctx, org_model.FindOrgOptions{
296316
ListOptions: db.ListOptionsAll,
297317
UserID: u.ID,

routers/web/web.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,10 @@ func registerRoutes(m *web.Router) {
757757
m.Post("/delete", admin.DeleteEmail)
758758
})
759759

760+
m.Group("/ips", func() {
761+
m.Get("", admin.IPs)
762+
})
763+
760764
m.Group("/orgs", func() {
761765
m.Get("", admin.Organizations)
762766
})
@@ -816,7 +820,7 @@ func registerRoutes(m *web.Router) {
816820
addSettingsRunnersRoutes()
817821
addSettingsVariablesRoutes()
818822
})
819-
}, adminReq, ctxDataSet("EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled))
823+
}, adminReq, ctxDataSet("RecordUserSignupMetadata", setting.RecordUserSignupMetadata, "EnableOAuth2", setting.OAuth2.Enabled, "EnablePackages", setting.Packages.Enabled))
820824
// ***** END: Admin *****
821825

822826
m.Group("", func() {

templates/admin/ips/list.tmpl

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin user")}}
2+
<div class="admin-setting-content">
3+
<h4 class="ui top attached header">
4+
{{ctx.Locale.Tr "admin.ips.ip_manage_panel"}} ({{ctx.Locale.Tr "admin.total" .Total}})
5+
</h4>
6+
<div class="ui attached segment">
7+
<div class="ui secondary filter menu tw-items-center tw-mx-0">
8+
<form class="ui form ignore-dirty tw-flex-1">
9+
{{template "shared/search/combo" dict "Value" .Keyword}}
10+
</form>
11+
<!-- Sort -->
12+
<div class="ui dropdown type jump item tw-mr-0">
13+
<span class="text">
14+
{{ctx.Locale.Tr "repo.issues.filter_sort"}}
15+
</span>
16+
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
17+
<div class="menu">
18+
<a class="{{if or (eq .SortType "ip") (not .SortType)}}active {{end}}item" href="?sort=ip&q={{$.Keyword}}">{{ctx.Locale.Tr "admin.ips.filter_sort.ip"}}</a>
19+
<a class="{{if eq .SortType "reverseip"}}active {{end}}item" href="?sort=reverseip&q={{$.Keyword}}">{{ctx.Locale.Tr "admin.ips.filter_sort.ip_reverse"}}</a>
20+
<a class="{{if eq .SortType "username"}}active {{end}}item" href="?sort=username&q={{$.Keyword}}">{{ctx.Locale.Tr "admin.ips.filter_sort.name"}}</a>
21+
<a class="{{if eq .SortType "reverseusername"}}active {{end}}item" href="?sort=reverseusername&q={{$.Keyword}}">{{ctx.Locale.Tr "admin.ips.filter_sort.name_reverse"}}</a>
22+
</div>
23+
</div>
24+
</div>
25+
</div>
26+
<div class="ui attached table segment">
27+
<table class="ui very basic striped table unstackable">
28+
<thead>
29+
<tr>
30+
<th data-sortt-asc="username" data-sortt-desc="reverseusername">
31+
{{ctx.Locale.Tr "admin.users.name"}}
32+
{{SortArrow "username" "reverseusername" $.SortType false}}
33+
</th>
34+
<th>{{ctx.Locale.Tr "admin.users.full_name"}}</th>
35+
<th data-sortt-asc="ip" data-sortt-desc="reverseip" data-sortt-default="true">
36+
{{ctx.Locale.Tr "admin.ips.ip"}}
37+
{{SortArrow "ip" "reverseip" $.SortType true}}
38+
</th>
39+
</tr>
40+
</thead>
41+
<tbody>
42+
{{range .UserIPs}}
43+
<tr>
44+
<td><a href="{{AppSubUrl}}/-/admin/users/{{.UID}}">{{.Name}}</a></td>
45+
<td>{{.FullName}}</td>
46+
<td><a href="?q={{.IP}}&sort={{$.SortType}}">{{.IP}}</a></td>
47+
</tr>
48+
{{else}}
49+
<tr><td class="tw-text-center" colspan="3">{{ctx.Locale.Tr "no_results_found"}}</td></tr>
50+
{{end}}
51+
</tbody>
52+
</table>
53+
</div>
54+
55+
{{template "base/paginate" .}}
56+
</div>
57+
58+
{{template "admin/layout_footer" .}}

templates/admin/navbar.tmpl

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
</a>
1414
</div>
1515
</details>
16-
<details class="item toggleable-item" {{if or .PageIsAdminUsers .PageIsAdminEmails .PageIsAdminOrganizations .PageIsAdminAuthentications}}open{{end}}>
16+
<details class="item toggleable-item" {{if or .PageIsAdminUsers .PageIsAdminEmails .PageIsAdminIPs .PageIsAdminOrganizations .PageIsAdminAuthentications}}open{{end}}>
1717
<summary>{{ctx.Locale.Tr "admin.identity_access"}}</summary>
1818
<div class="menu">
1919
<a class="{{if .PageIsAdminAuthentications}}active {{end}}item" href="{{AppSubUrl}}/-/admin/auths">
@@ -28,6 +28,11 @@
2828
<a class="{{if .PageIsAdminEmails}}active {{end}}item" href="{{AppSubUrl}}/-/admin/emails">
2929
{{ctx.Locale.Tr "admin.emails"}}
3030
</a>
31+
{{if .RecordUserSignupMetadata}}
32+
<a class="{{if .PageIsAdminIPs}}active {{end}}item" href="{{AppSubUrl}}/-/admin/ips">
33+
{{ctx.Locale.Tr "admin.ips"}}
34+
</a>
35+
{{end}}
3136
</div>
3237
</details>
3338
<details class="item toggleable-item" {{if or .PageIsAdminRepositories (and .EnablePackages .PageIsAdminPackages)}}open{{end}}>

templates/admin/user/view.tmpl

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@
2525
</div>
2626
</div>
2727
</div>
28+
{{if .ShowUserSignupMetadata}}
29+
<h4 class="ui top attached header">
30+
{{ctx.Locale.Tr "admin.ips.signup_metadata"}}
31+
</h4>
32+
<div class="ui attached segment">
33+
{{template "admin/user/view_ip" .}}
34+
</div>
35+
{{end}}
2836
<h4 class="ui top attached header">
2937
{{ctx.Locale.Tr "admin.repositories"}}
3038
<div class="ui right">

templates/admin/user/view_ip.tmpl

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{{if .HasSignupIP}}
2+
<div class="flex-list">
3+
<div class="flex-item">
4+
<div class="flex-item-main">
5+
<div class="flex-text-block">
6+
<strong>{{ctx.Locale.Tr "admin.ips.ip"}}:</strong> <a href="{{AppSubUrl}}/-/admin/ips?q={{.SignupIP}}">{{.SignupIP}}</a>
7+
</div>
8+
{{if .HasSignupUserAgent}}
9+
<div class="flex-text-block">
10+
<strong>{{ctx.Locale.Tr "admin.ips.user_agent"}}:</strong> {{.SignupUserAgent}}
11+
</div>
12+
{{end}}
13+
</div>
14+
</div>
15+
</div>
16+
{{else}}
17+
<div>{{ctx.Locale.Tr "admin.ips.not_available"}}</div>
18+
{{end}}

0 commit comments

Comments
 (0)