Skip to content

Commit fd619a1

Browse files
committed
BLENDER: Add Admin page for Issues or Comments that contain external links
1 parent 6882177 commit fd619a1

File tree

10 files changed

+378
-24
lines changed

10 files changed

+378
-24
lines changed

models/issues/comment.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1313,3 +1313,21 @@ func InsertIssueComments(ctx context.Context, comments []*Comment) error {
13131313
}
13141314
return committer.Commit()
13151315
}
1316+
1317+
// GetRecentComments returns the most recent issue comments
1318+
func GetRecentComments(ctx context.Context, opts *db.ListOptions) ([]*Comment, error) {
1319+
sess := db.GetEngine(ctx).
1320+
Where("type = ?", CommentTypeComment).
1321+
OrderBy("created_unix DESC")
1322+
1323+
if opts != nil {
1324+
sess = db.SetSessionPagination(sess, opts)
1325+
}
1326+
1327+
cap := 0
1328+
if opts != nil {
1329+
cap = opts.PageSize
1330+
}
1331+
comments := make([]*Comment, 0, cap)
1332+
return comments, sess.Find(&comments)
1333+
}

models/issues/issue.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -824,3 +824,21 @@ func ChangeIssueTimeEstimate(ctx context.Context, issue *Issue, doer *user_model
824824
return nil
825825
})
826826
}
827+
828+
// GetRecentIssues returns the most recently created issues
829+
func GetRecentIssues(ctx context.Context, opts *db.ListOptions) ([]*Issue, error) {
830+
sess := db.GetEngine(ctx).
831+
Where("is_pull = ?", false).
832+
OrderBy("created_unix DESC")
833+
834+
if opts != nil {
835+
sess = db.SetSessionPagination(sess, opts)
836+
}
837+
838+
cap := 0
839+
if opts != nil {
840+
cap = opts.PageSize
841+
}
842+
issues := make([]*Issue, 0, cap)
843+
return issues, sess.Find(&issues)
844+
}

options/locale/locale_en-US.ini

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,8 @@ filter.private = Private
167167
no_results_found = No results found.
168168
internal_error_skipped = Internal error occurred but is skipped: %s
169169

170+
type = Type
171+
170172
[search]
171173
search = Search...
172174
type_tooltip = Search type
@@ -2957,8 +2959,15 @@ first_page = First
29572959
last_page = Last
29582960
total = Total: %d
29592961
settings = Admin Settings
2962+
2963+
spam_management = Spam Management
29602964
spamreports = Spam Reports
2961-
users_with_links = Users (Potential Spam)
2965+
users_with_links = Users with Links
2966+
issues_with_links = Issues/Comments with Links
2967+
issues_with_links.found_links = Found Links
2968+
issues_with_links.created = Created
2969+
issues_with_links.updated = Updated
2970+
issues_with_links.user.created = User Created
29622971
29632972
dashboard.new_version_hint = Gitea %s is now available, you are running %s. Check <a target="_blank" rel="noreferrer" href="%s">the blog</a> for more details.
29642973
dashboard.statistic = Summary

routers/utils/utils.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,17 @@ import (
1212
func SanitizeFlashErrorString(x string) string {
1313
return strings.ReplaceAll(html.EscapeString(x), "\n", "<br>")
1414
}
15+
16+
func ContainsHyperlink(text string) bool {
17+
text = strings.ToLower(text)
18+
return strings.Contains(text, "http://") || strings.Contains(text, "https://")
19+
}
20+
21+
func ContainsExcludedDomain(snippet string, domains []string) bool {
22+
for _, domain := range domains {
23+
if strings.Contains(snippet, domain) {
24+
return true
25+
}
26+
}
27+
return false
28+
}
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
package admin
2+
3+
import (
4+
"net/http"
5+
"net/url"
6+
"regexp"
7+
"sort"
8+
"strings"
9+
"time"
10+
11+
"code.gitea.io/gitea/modules/log"
12+
13+
issue_model "code.gitea.io/gitea/models/issues"
14+
repo_model "code.gitea.io/gitea/models/repo"
15+
"code.gitea.io/gitea/modules/setting"
16+
"code.gitea.io/gitea/modules/templates"
17+
"code.gitea.io/gitea/models/db"
18+
"code.gitea.io/gitea/services/context"
19+
user_model "code.gitea.io/gitea/models/user"
20+
)
21+
22+
const tplIssuesWithLinks templates.TplName = "admin/issues_with_links"
23+
24+
type linkItem struct {
25+
Type string
26+
Content string
27+
User *user_model.User
28+
UserCreated time.Time
29+
RepoName string
30+
RepoLink string
31+
ItemLink string
32+
Created time.Time
33+
Updated time.Time
34+
}
35+
36+
func IssuesWithLinks(ctx *context.Context) {
37+
ctx.Data["Title"] = ctx.Tr("admin.issues_with_links")
38+
ctx.Data["PageIsIssuesWithLinks"] = true
39+
40+
page := ctx.FormInt("page")
41+
if page <= 1 {
42+
page = 1
43+
}
44+
45+
sortField := ctx.FormString("sort")
46+
sortOrder := strings.ToLower(ctx.FormString("order")) // asc or desc
47+
if sortOrder != "asc" {
48+
sortOrder = "desc"
49+
}
50+
ctx.Data["Sort"] = sortField
51+
ctx.Data["Order"] = sortOrder
52+
53+
// fetch recent issues and comments
54+
limit := setting.UI.Admin.UserPagingNum
55+
issues, err := issue_model.GetRecentIssues(ctx, &db.ListOptions{Page: page, PageSize: limit})
56+
if err != nil {
57+
ctx.ServerError("GetRecentIssues", err)
58+
return
59+
}
60+
comments, err := issue_model.GetRecentComments(ctx, &db.ListOptions{Page: page, PageSize: limit})
61+
if err != nil {
62+
ctx.ServerError("GetRecentComments", err)
63+
return
64+
}
65+
66+
var excludedDomains = []string{
67+
getDomain(setting.AppURL),
68+
"github.com",
69+
}
70+
71+
var items []linkItem
72+
appendIfHasLink := func(typeLabel, content, itemLink, repoName string, repoLink string, created time.Time, updated time.Time, u *user_model.User) {
73+
links := extractAllLinks(content)
74+
if len(links) == 0 {
75+
return
76+
}
77+
78+
var validLinks []string
79+
for _, link := range links {
80+
if !containsExcludedDomain(link, excludedDomains) {
81+
validLinks = append(validLinks, link)
82+
}
83+
}
84+
85+
if len(validLinks) == 0 {
86+
return
87+
}
88+
89+
items = append(items, linkItem{
90+
Type: typeLabel,
91+
Content: strings.Join(validLinks, ", "),
92+
User: u,
93+
UserCreated: u.CreatedUnix.AsTime(),
94+
RepoName: repoName,
95+
RepoLink: repoLink,
96+
ItemLink: itemLink,
97+
Created: created,
98+
Updated: updated,
99+
})
100+
}
101+
102+
for _, issue := range issues {
103+
if issue.Content == "" {
104+
continue
105+
}
106+
if issue.Repo == nil {
107+
issue.Repo, err = repo_model.GetRepositoryByID(ctx, issue.RepoID)
108+
if err != nil {
109+
log.Warn("Could not load repo for issue %d: %v", issue.ID, err)
110+
continue
111+
}
112+
}
113+
u, err := user_model.GetUserByID(ctx, issue.PosterID)
114+
if err != nil {
115+
continue
116+
}
117+
appendIfHasLink("Issue", issue.Content, issue.HTMLURL(), issue.Repo.Name, issue.Repo.HTMLURL(), issue.CreatedUnix.AsTime(), issue.UpdatedUnix.AsTime(), u)
118+
}
119+
120+
for _, comment := range comments {
121+
if comment.Issue == nil {
122+
comment.Issue, err = issue_model.GetIssueByID(ctx, comment.IssueID)
123+
if err != nil {
124+
log.Warn("Could not load issue for comment %d: %v", comment.ID, err)
125+
continue
126+
}
127+
}
128+
if comment.Issue.Repo == nil {
129+
comment.Issue.Repo, err = repo_model.GetRepositoryByID(ctx, comment.Issue.RepoID)
130+
if err != nil {
131+
log.Warn("Could not load repo for issue %d: %v", comment.Issue.ID, err)
132+
continue
133+
}
134+
}
135+
if comment.Content == "" {
136+
continue
137+
}
138+
u, err := user_model.GetUserByID(ctx, comment.PosterID)
139+
if err != nil {
140+
continue
141+
}
142+
appendIfHasLink("Comment", comment.Content, comment.HTMLURL(ctx), comment.Issue.Repo.Name, comment.Issue.Repo.HTMLURL(), comment.CreatedUnix.AsTime(), comment.UpdatedUnix.AsTime(), u)
143+
}
144+
145+
// Sort
146+
switch sortField {
147+
case "usercreated":
148+
sort.Slice(items, func(i, j int) bool {
149+
if sortOrder == "asc" {
150+
return items[i].UserCreated.Before(items[j].UserCreated)
151+
}
152+
return items[i].UserCreated.After(items[j].UserCreated)
153+
})
154+
case "created":
155+
sort.Slice(items, func(i, j int) bool {
156+
if sortOrder == "asc" {
157+
return items[i].Created.Before(items[j].Created)
158+
}
159+
return items[i].Created.After(items[j].Created)
160+
})
161+
default: // fallback to descending by UserCreated
162+
sort.Slice(items, func(i, j int) bool {
163+
return items[i].UserCreated.After(items[j].UserCreated)
164+
})
165+
}
166+
167+
total := len(items)
168+
start := (page - 1) * limit
169+
end := start + limit
170+
if start > total {
171+
start = total
172+
}
173+
if end > total {
174+
end = total
175+
}
176+
paged := items[start:end]
177+
178+
ctx.Data["Items"] = paged
179+
ctx.Data["Total"] = total
180+
181+
pager := context.NewPagination(total, limit, page, 5)
182+
pager.AddParamFromRequest(ctx.Req)
183+
ctx.Data["Page"] = pager
184+
185+
ctx.HTML(http.StatusOK, tplIssuesWithLinks)
186+
}
187+
188+
// extractAllLinks returns all http(s) URLs from raw text and Markdown-style links.
189+
func extractAllLinks(text string) []string {
190+
// Match Markdown-style links: [label](http://example.com)
191+
mdLinkRegex := regexp.MustCompile(`\[[^\]]*\]\((https?://[^\s\)]+)\)`)
192+
// Match raw URLs
193+
rawURLRegex := regexp.MustCompile(`https?://[^\s<>"')\]]+`)
194+
195+
seen := make(map[string]bool)
196+
var links []string
197+
198+
// Extract Markdown links
199+
for _, match := range mdLinkRegex.FindAllStringSubmatch(text, -1) {
200+
if len(match) > 1 {
201+
url := match[1]
202+
if !seen[url] {
203+
seen[url] = true
204+
links = append(links, url)
205+
}
206+
}
207+
}
208+
209+
// Extract raw URLs
210+
for _, url := range rawURLRegex.FindAllString(text, -1) {
211+
if !seen[url] {
212+
seen[url] = true
213+
links = append(links, url)
214+
}
215+
}
216+
217+
return links
218+
}
219+
220+
// containsExcludedDomain returns true if any domain in the list matches the link
221+
func containsExcludedDomain(link string, excluded []string) bool {
222+
u, err := url.Parse(link)
223+
if err != nil || u.Host == "" {
224+
return false
225+
}
226+
for _, d := range excluded {
227+
if strings.EqualFold(u.Hostname(), d) {
228+
return true
229+
}
230+
}
231+
return false
232+
}
233+
234+
// getDomain extracts domain from full AppURL (e.g. https://example.com)
235+
func getDomain(raw string) string {
236+
u, err := url.Parse(raw)
237+
if err != nil {
238+
return ""
239+
}
240+
return u.Hostname()
241+
}

routers/web/admin/users_with_links.go

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ package admin
22

33
import (
44
"net/http"
5-
"strings"
65

76
user_model "code.gitea.io/gitea/models/user"
7+
"code.gitea.io/gitea/routers/utils"
88
"code.gitea.io/gitea/modules/optional"
99
"code.gitea.io/gitea/modules/setting"
1010
"code.gitea.io/gitea/modules/templates"
@@ -16,8 +16,8 @@ const tplUsersWithLinks templates.TplName = "admin/users_with_links"
1616

1717
// UsersWithLinks renders a list of users that contain hyperlinks in bio fields
1818
func UsersWithLinks(ctx *context.Context) {
19-
ctx.Data["Title"] = ctx.Tr("admin.users.with_links")
20-
ctx.Data["PageIsAdminUsers"] = true
19+
ctx.Data["Title"] = ctx.Tr("admin.users_with_links")
20+
ctx.Data["PageIsUsersWithLinks"] = true
2121

2222
// Parse filters from query parameters
2323
statusActive := ctx.FormString("status_filter[is_active]")
@@ -60,8 +60,8 @@ func UsersWithLinks(ctx *context.Context) {
6060
// Filter users with hyperlinks in bio fields
6161
filtered := make([]*user_model.User, 0, len(users))
6262
for _, u := range users {
63-
if containsHyperlink(u.FullName) || containsHyperlink(u.Description) ||
64-
containsHyperlink(u.Location) || containsHyperlink(u.Website) {
63+
if utils.ContainsHyperlink(u.FullName) || utils.ContainsHyperlink(u.Description) ||
64+
utils.ContainsHyperlink(u.Location) || utils.ContainsHyperlink(u.Website) {
6565
filtered = append(filtered, u)
6666
}
6767
}
@@ -77,8 +77,3 @@ func UsersWithLinks(ctx *context.Context) {
7777

7878
ctx.HTML(http.StatusOK, tplUsersWithLinks)
7979
}
80-
81-
func containsHyperlink(text string) bool {
82-
text = strings.ToLower(text)
83-
return strings.Contains(text, "http://") || strings.Contains(text, "https://")
84-
}

routers/web/web.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -761,15 +761,19 @@ func registerWebRoutes(m *web.Router) {
761761
})
762762

763763
// BLENDER: spam reporting
764-
m.Group("/users_with_links", func() {
765-
m.Get("", admin.UsersWithLinks)
766-
})
767764
m.Group("/spamreports", func() {
768765
m.Get("", admin.SpamReports)
769766
m.Post("", admin.SpamReportsPost)
770767
})
771768
m.Post("/purge_spammer", admin.PurgeSpammerPost)
772769

770+
m.Group("/users_with_links", func() {
771+
m.Get("", admin.UsersWithLinks)
772+
})
773+
m.Group("/issues_with_links", func() {
774+
m.Get("", admin.IssuesWithLinks)
775+
})
776+
773777
m.Group("/orgs", func() {
774778
m.Get("", admin.Organizations)
775779
})

0 commit comments

Comments
 (0)