~gitpocket/gitpocket

8

feat(moderation): add reports, account blocks and hourly thread limits

commit
ca96986a2b8a683049db1edfcd185b75870b05ee browse tree
parent
34af2a46dac62a9edd1dda31e5d635845c11f6d0
author
~james <354576047+[email protected]>
signature
Signed by: ~james SHA256:KlzJ0P0gvJcnOYpaxD5yCUw52ImHttTKRjVAEnnYnKg
date
2026-11-13 22:41 UTC

19 files changed, +605 −12

README.md modified

@@ -22,6 +22,7 @@ The web interface is then on `127.0.0.1:3200` and wants a reverse proxy with TLS
2222 - Organisations, collaborators, issues, labels, stars, forks, branch protection and per owner disk quotas enforced during the push
2323 - Profiles with a pinned repository list, an activity graph, followers and a README from your `~you/you` repository
2424 - Imports from GitHub, GitLab, Gitea or Forgejo over https, with the option to credit commits made under your old identity to your account
25+- Reports to admins and per account blocking, with hourly limits on new issues, pull requests and repositories
2526 - SSH and PGP commit verification, TOTP, access tokens and Atom feeds for commits, issues and pull requests
2627 - Site search over repositories and people with language facets
2728 - Light and dark themes, light by default, with an option to follow the device

forge/admin.go modified

@@ -165,7 +165,7 @@ func (s *Store) Accounts() []account {
165165 }
166166
167167 var adminSections = []struct{ Key, Title string }{
168- {"overview", "overview"}, {"people", "people"}, {"signup", "sign up"}, {"projects", "front page"}, {"storage", "storage"},
168+ {"overview", "overview"}, {"people", "people"}, {"signup", "sign up"}, {"projects", "front page"}, {"storage", "storage"}, {"reports", "reports"},
169169 }
170170
171171 type siteStats struct {
@@ -200,7 +200,7 @@ func (s *Server) admin(w http.ResponseWriter, r *http.Request) {
200200 if !known {
201201 section = "overview"
202202 }
203- data := map[string]any{"Section": section, "Sections": adminSections, "Saved": r.URL.Query().Get("saved") != ""}
203+ data := map[string]any{"Section": section, "Sections": adminSections, "Saved": r.URL.Query().Get("saved") != "", "OpenReports": s.store.OpenReports()}
204204 switch section {
205205 case "overview":
206206 accounts := s.store.Accounts()
@@ -231,6 +231,12 @@ func (s *Server) admin(w http.ResponseWriter, r *http.Request) {
231231 }
232232 public, _ := s.store.PublicRepos("", "")
233233 data["Ranks"], data["Public"] = ranks, public
234+ case "reports":
235+ state := r.URL.Query().Get("state")
236+ if state != "resolved" && state != "dismissed" {
237+ state = "open"
238+ }
239+ data["Reports"], data["State"] = s.store.Reports(state), state
234240 case "storage":
235241 quota := readQuota(s.root)
236242 type owner struct {
@@ -312,6 +318,15 @@ func (s *Server) adminAction(w http.ResponseWriter, r *http.Request, u *User) {
312318 fail("No such user")
313319 return
314320 }
321+ case "moderate":
322+ if !s.moderate(w, r, u, fail) {
323+ return
324+ }
325+ case "pages", "nopages":
326+ if err := s.store.SetPages(name, r.FormValue("action") == "pages"); err != nil {
327+ fail("No such account")
328+ return
329+ }
315330 case "feature":
316331 owner, repo, ok := strings.Cut(name, "/")
317332 rank, _ := strconv.Atoi(r.FormValue("rank"))

forge/extra.go modified

@@ -25,7 +25,12 @@ func (s *Server) star(w http.ResponseWriter, r *http.Request, repo Repo) {
2525 if u == nil {
2626 return
2727 }
28- s.store.Star(u.ID, repo.ID, !s.store.Starred(u.ID, repo.ID))
28+ on := !s.store.Starred(u.ID, repo.ID)
29+ if on && s.shunned(repo, u) {
30+ s.fail(w, r, 403, "You cannot star this repository")
31+ return
32+ }
33+ s.store.Star(u.ID, repo.ID, on)
2934 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
3035 }
3136

forge/feature_test.go added

@@ -0,0 +1,91 @@
1+package forge
2+
3+import (
4+ "net/http"
5+ "net/url"
6+ "strconv"
7+ "strings"
8+ "testing"
9+)
10+
11+func TestModeration(t *testing.T) {
12+ s, h := testServer(t)
13+ alice, _ := s.store.CreateUser("alice", "correct horse")
14+ bob, _ := s.store.CreateUser("bob", "correct horse")
15+ boss, _ := s.store.CreateUser("boss", "correct horse")
16+ s.store.SetAdmin("boss", true)
17+ s.store.CreateRepo(alice, "proj", "", Public, "")
18+ s.store.CreateRepo(alice, "hidden", "", Private, "")
19+ a, _ := s.store.CreateSession(alice.ID)
20+ b, _ := s.store.CreateSession(bob.ID)
21+ admin, _ := s.store.CreateSession(boss.ID)
22+
23+ if w := post(h, "/~alice/proj/issues/new", url.Values{"title": {"spam"}, "body": {"x"}}, b); w.Code != http.StatusSeeOther {
24+ t.Fatalf("issue: %d", w.Code)
25+ }
26+ if w := post(h, "/report", url.Values{"kind": {"issue"}, "target": {"alice/proj#1"}, "reason": {"spam"}}, a); w.Code != 200 {
27+ t.Fatalf("report: %d %s", w.Code, w.Body.String())
28+ }
29+ if w := post(h, "/report", url.Values{"kind": {"issue"}, "target": {"alice/proj#1"}, "reason": {"spam"}}, a); w.Code != 400 {
30+ t.Error("duplicate report accepted")
31+ }
32+ if w := post(h, "/report", url.Values{"kind": {"repo"}, "target": {"alice/hidden"}, "reason": {"x"}}, b); w.Code != 404 {
33+ t.Error("report reveals a private repository")
34+ }
35+ if w := get(h, "/admin?s=reports", admin); !strings.Contains(w.Body.String(), "alice/proj#1") {
36+ t.Error("report missing from admin")
37+ }
38+ if w := get(h, "/admin?s=reports", b); w.Code == 200 {
39+ t.Error("non-admin sees reports")
40+ }
41+ rep := s.store.Reports("open")[0]
42+ if w := post(h, "/admin", url.Values{"s": {"reports"}, "action": {"moderate"}, "report": {itoa(rep.ID)}, "do": {"ban"}}, admin); strings.Contains(w.Header().Get("Location"), "error") {
43+ t.Fatalf("ban: %s", w.Header().Get("Location"))
44+ }
45+ if !s.store.IsBanned(bob.ID) || s.store.IsBanned(alice.ID) {
46+ t.Error("ban hit the wrong account")
47+ }
48+ s.store.SetBanned("bob", false)
49+ b, _ = s.store.CreateSession(bob.ID)
50+ if len(s.store.Reports("open")) != 0 {
51+ t.Error("report still open")
52+ }
53+
54+ if w := post(h, "/block", url.Values{"name": {"bob"}}, a); w.Code != http.StatusSeeOther {
55+ t.Fatalf("block: %d", w.Code)
56+ }
57+ if w := post(h, "/~alice/proj/issues/new", url.Values{"title": {"again"}}, b); w.Code != 403 {
58+ t.Errorf("blocked user opened an issue: %d", w.Code)
59+ }
60+ if w := post(h, "/~alice/proj/issues/1/comment", url.Values{"body": {"hi"}}, b); w.Code != 403 {
61+ t.Errorf("blocked user commented: %d", w.Code)
62+ }
63+ if w := post(h, "/~alice/proj/star", nil, b); w.Code != 403 {
64+ t.Errorf("blocked user starred: %d", w.Code)
65+ }
66+ if w := post(h, "/~alice/follow", nil, b); w.Code != 403 {
67+ t.Errorf("blocked user followed: %d", w.Code)
68+ }
69+ if w := post(h, "/~alice/proj/fork", nil, b); w.Code != 403 {
70+ t.Errorf("blocked user forked: %d", w.Code)
71+ }
72+ if !strings.Contains(get(h, "/settings", a).Body.String(), "~bob") {
73+ t.Error("blocked list missing from settings")
74+ }
75+ post(h, "/block", url.Values{"name": {"bob"}, "action": {"unblock"}}, a)
76+ if s.store.Blocked(alice.ID, bob.ID) {
77+ t.Error("unblock failed")
78+ }
79+ if w := post(h, "/block", url.Values{"name": {"bob"}}, b); w.Code != 400 {
80+ t.Error("blocked yourself")
81+ }
82+
83+ for i := 0; i < threadsPerHour; i++ {
84+ s.store.CreateIssue(Repo{ID: 1}, bob, "t", "")
85+ }
86+ if w := post(h, "/~alice/proj/issues/new", url.Values{"title": {"flood"}}, b); w.Code != 429 {
87+ t.Errorf("issue flood not limited: %d", w.Code)
88+ }
89+}
90+
91+func itoa(n int64) string { return strconv.FormatInt(n, 10) }

forge/flow_test.go modified

@@ -251,7 +251,7 @@ func TestAdminSections(t *testing.T) {
251251 admin, _ := s.store.CreateUser("root", "correct horse")
252252 s.store.SetAdmin("root", true)
253253 id, _ := s.store.CreateSession(admin.ID)
254- for _, sec := range []string{"overview", "people", "signup", "projects", "storage", "bogus"} {
254+ for _, sec := range []string{"overview", "people", "signup", "projects", "storage", "reports", "bogus"} {
255255 if w := get(h, "/admin?s="+sec, id); w.Code != 200 {
256256 t.Errorf("section %s: %d", sec, w.Code)
257257 }

forge/follow.go modified

@@ -50,7 +50,12 @@ func (s *Server) follow(w http.ResponseWriter, r *http.Request, owner User) {
5050 return
5151 }
5252 if u.ID != owner.ID && !s.store.IsOrg(u.ID) {
53- s.store.Follow(u.ID, owner.ID, !s.store.Follows(u.ID, owner.ID))
53+ on := !s.store.Follows(u.ID, owner.ID)
54+ if on && s.store.Blocked(owner.ID, u.ID) {
55+ s.fail(w, r, 403, "You cannot follow this account")
56+ return
57+ }
58+ s.store.Follow(u.ID, owner.ID, on)
5459 }
5560 http.Redirect(w, r, "/~"+owner.Name, http.StatusSeeOther)
5661 }

forge/issues.go modified

@@ -186,6 +186,14 @@ func (s *Server) createIssue(w http.ResponseWriter, r *http.Request, repo Repo)
186186 s.fail(w, r, 403, "This repository is archived")
187187 return
188188 }
189+ if s.shunned(repo, user) {
190+ s.fail(w, r, 403, "You cannot open issues here")
191+ return
192+ }
193+ if !s.store.ThreadBudget(user.ID) {
194+ s.fail(w, r, 429, "Too many new issues and pull requests, try again later")
195+ return
196+ }
189197 issue, err := s.store.CreateIssue(repo, *user, r.FormValue("title"), strings.TrimSpace(r.FormValue("body")))
190198 if err != nil {
191199 w.WriteHeader(400)
@@ -233,6 +241,10 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request, repo Repo, rest s
233241 s.fail(w, r, 403, "This repository is archived")
234242 return
235243 }
244+ if s.shunned(repo, user) {
245+ s.fail(w, r, 403, "You cannot comment here")
246+ return
247+ }
236248 if err := s.store.AddIssueComment(issue.ID, *user, r.FormValue("body")); err != nil {
237249 http.Redirect(w, r, back+"?error="+template.URLQueryEscaper(err.Error()), http.StatusSeeOther)
238250 return

forge/moderation.go added

@@ -0,0 +1,393 @@
1+package forge
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "net/http"
7+ "net/url"
8+ "os"
9+ "strconv"
10+ "strings"
11+ "time"
12+)
13+
14+const moderationSchema = `
15+create table if not exists blocks (
16+ user integer not null references users(id) on delete cascade,
17+ blocked integer not null references users(id) on delete cascade,
18+ created integer not null,
19+ primary key (user, blocked)
20+);
21+create table if not exists reports (
22+ id integer primary key,
23+ reporter integer not null references users(id) on delete cascade,
24+ kind text not null,
25+ target text not null,
26+ reason text not null,
27+ state text not null default 'open',
28+ handler integer not null default 0,
29+ created integer not null
30+);
31+create index if not exists reports_state on reports(state, created);
32+create table if not exists guestpushes (
33+ user integer not null references users(id) on delete cascade,
34+ created integer not null
35+);
36+create index if not exists guestpushes_user on guestpushes(user, created);
37+`
38+
39+const (
40+ guestPushes = 30
41+ threadsPerHour = 20
42+ reportsPerHour = 5
43+ maxReason = 2000
44+)
45+
46+var reportKinds = map[string]string{"user": "account", "repo": "repository", "issue": "issue", "pull": "pull request"}
47+
48+type Report struct {
49+ ID int64
50+ Reporter User
51+ Kind string
52+ Target string
53+ Reason string
54+ State string
55+ Created time.Time
56+}
57+
58+func (r Report) Link() string {
59+ switch r.Kind {
60+ case "user":
61+ return "/~" + r.Target
62+ case "repo":
63+ return "/~" + r.Target
64+ case "issue", "pull":
65+ repo, num, _ := strings.Cut(r.Target, "#")
66+ section := map[string]string{"issue": "issues", "pull": "pulls"}[r.Kind]
67+ return "/~" + repo + "/" + section + "/" + num
68+ }
69+ return "/"
70+}
71+
72+func (r Report) Owner() string {
73+ name, _, _ := strings.Cut(r.Target, "/")
74+ name, _, _ = strings.Cut(name, "#")
75+ return name
76+}
77+
78+func (s *Store) IsBanned(id int64) bool {
79+ var banned bool
80+ s.db.QueryRow(`select banned from users where id = ?`, id).Scan(&banned)
81+ return banned
82+}
83+
84+func (s *Store) PagesAllowed(id int64) bool {
85+ var on bool
86+ s.db.QueryRow(`select pages from users where id = ?`, id).Scan(&on)
87+ return on
88+}
89+
90+func (s *Store) SetPages(name string, on bool) error {
91+ res, err := s.db.Exec(`update users set pages = ? where name = ?`, on, name)
92+ if err != nil {
93+ return err
94+ }
95+ if n, _ := res.RowsAffected(); n == 0 {
96+ return ErrNotFound
97+ }
98+ return nil
99+}
100+
101+func (s *Store) Blocked(owner, actor int64) bool {
102+ var n int
103+ s.db.QueryRow(`select count(*) from blocks where user = ? and blocked = ?`, owner, actor).Scan(&n)
104+ return n > 0
105+}
106+
107+func (s *Store) SetBlock(user, blocked int64, on bool) error {
108+ if !on {
109+ _, err := s.db.Exec(`delete from blocks where user = ? and blocked = ?`, user, blocked)
110+ return err
111+ }
112+ tx, err := s.db.Begin()
113+ if err != nil {
114+ return err
115+ }
116+ defer tx.Rollback()
117+ if _, err := tx.Exec(`insert or ignore into blocks(user, blocked, created) values (?, ?, ?)`, user, blocked, time.Now().Unix()); err != nil {
118+ return err
119+ }
120+ if _, err := tx.Exec(`delete from follows where (follower = ?1 and followee = ?2) or (follower = ?2 and followee = ?1)`, blocked, user); err != nil {
121+ return err
122+ }
123+ if _, err := tx.Exec(`delete from stars where user = ? and repo in (select id from repos where owner = ?)`, blocked, user); err != nil {
124+ return err
125+ }
126+ return tx.Commit()
127+}
128+
129+func (s *Store) BlockedUsers(user int64) []User {
130+ rows, err := s.db.Query(`select u.id, u.name, u.created from blocks b join users u on u.id = b.blocked where b.user = ? order by u.name`, user)
131+ if err != nil {
132+ return nil
133+ }
134+ defer rows.Close()
135+ var out []User
136+ for rows.Next() {
137+ var u User
138+ var created int64
139+ if rows.Scan(&u.ID, &u.Name, &created) == nil {
140+ u.Created = time.Unix(created, 0)
141+ out = append(out, u)
142+ }
143+ }
144+ return out
145+}
146+
147+func (s *Store) ThreadBudget(user int64) bool {
148+ since := time.Now().Add(-time.Hour).Unix()
149+ var n int
150+ s.db.QueryRow(`select (select count(*) from issues where author = ?1 and created > ?2) + (select count(*) from pulls where author = ?1 and created > ?2)`, user, since).Scan(&n)
151+ return n < threadsPerHour
152+}
153+
154+func (s *Store) GuestPushBudget(user int64) bool {
155+ var n int
156+ s.db.QueryRow(`select count(*) from guestpushes where user = ? and created > ?`, user, time.Now().Add(-time.Hour).Unix()).Scan(&n)
157+ return n < guestPushes
158+}
159+
160+func (s *Store) GuestPushed(user int64) {
161+ now := time.Now()
162+ s.db.Exec(`insert into guestpushes(user, created) values (?, ?)`, user, now.Unix())
163+ s.db.Exec(`delete from guestpushes where created < ?`, now.Add(-time.Hour).Unix())
164+}
165+
166+func (s *Store) AddReport(reporter int64, kind, target, reason string) error {
167+ if _, ok := reportKinds[kind]; !ok {
168+ return errors.New("Unknown report type")
169+ }
170+ reason = strings.TrimSpace(reason)
171+ if reason == "" {
172+ return errors.New("Say what is wrong")
173+ }
174+ if len(reason) > maxReason {
175+ return fmt.Errorf("Keep the report under %d characters", maxReason)
176+ }
177+ var recent, dup int
178+ s.db.QueryRow(`select count(*) from reports where reporter = ? and created > ?`, reporter, time.Now().Add(-time.Hour).Unix()).Scan(&recent)
179+ if recent >= reportsPerHour {
180+ return errors.New("Too many reports, try again later")
181+ }
182+ s.db.QueryRow(`select count(*) from reports where reporter = ? and kind = ? and target = ? and state = 'open'`, reporter, kind, target).Scan(&dup)
183+ if dup > 0 {
184+ return errors.New("You already reported this, an admin will look at it")
185+ }
186+ _, err := s.db.Exec(`insert into reports(reporter, kind, target, reason, created) values (?, ?, ?, ?, ?)`, reporter, kind, target, reason, time.Now().Unix())
187+ return err
188+}
189+
190+func (s *Store) Reports(state string) []Report {
191+ rows, err := s.db.Query(`select r.id, r.kind, r.target, r.reason, r.state, r.created, u.id, u.name from reports r join users u on u.id = r.reporter where r.state = ? order by r.created desc limit 200`, state)
192+ if err != nil {
193+ return nil
194+ }
195+ defer rows.Close()
196+ var out []Report
197+ for rows.Next() {
198+ var rep Report
199+ var created int64
200+ if rows.Scan(&rep.ID, &rep.Kind, &rep.Target, &rep.Reason, &rep.State, &created, &rep.Reporter.ID, &rep.Reporter.Name) == nil {
201+ rep.Created = time.Unix(created, 0)
202+ out = append(out, rep)
203+ }
204+ }
205+ return out
206+}
207+
208+func (s *Store) Report(id int64) (Report, error) {
209+ var rep Report
210+ var created int64
211+ err := s.db.QueryRow(`select id, kind, target, reason, state, created from reports where id = ?`, id).Scan(&rep.ID, &rep.Kind, &rep.Target, &rep.Reason, &rep.State, &created)
212+ rep.Created = time.Unix(created, 0)
213+ return rep, err
214+}
215+
216+func (s *Store) CloseReports(kind, target, state string, handler int64) {
217+ s.db.Exec(`update reports set state = ?, handler = ? where kind = ? and target = ? and state = 'open'`, state, handler, kind, target)
218+}
219+
220+func (s *Store) OpenReports() int {
221+ var n int
222+ s.db.QueryRow(`select count(*) from reports where state = 'open'`).Scan(&n)
223+ return n
224+}
225+
226+func (s *Server) shunned(repo Repo, u *User) bool {
227+ return u != nil && s.store.Blocked(repo.Owner.ID, u.ID)
228+}
229+
230+func (s *Server) reportTarget(kind, target string, viewer *User) (string, bool) {
231+ switch kind {
232+ case "user":
233+ u, err := s.store.UserByName(target)
234+ return u.Name, err == nil
235+ case "repo", "issue", "pull":
236+ path, num, hasNum := strings.Cut(target, "#")
237+ owner, name, ok := strings.Cut(path, "/")
238+ if !ok || hasNum != (kind != "repo") {
239+ return "", false
240+ }
241+ repo, err := s.store.RepoByPath(owner, name)
242+ if err != nil || (repo.Private && !s.store.CanWrite(repo, viewer)) {
243+ return "", false
244+ }
245+ bare := repo.Owner.Name + "/" + repo.Name
246+ if kind == "repo" {
247+ return bare, true
248+ }
249+ n, err := strconv.ParseInt(num, 10, 64)
250+ if err != nil {
251+ return "", false
252+ }
253+ if kind == "issue" {
254+ _, err = s.store.Issue(repo.ID, n)
255+ } else {
256+ _, err = s.store.Pull(repo, n)
257+ }
258+ return fmt.Sprintf("%s#%d", bare, n), err == nil
259+ }
260+ return "", false
261+}
262+
263+func (s *Server) report(w http.ResponseWriter, r *http.Request) {
264+ u := s.require(w, r)
265+ if u == nil {
266+ return
267+ }
268+ kind := r.FormValue("kind")
269+ target, ok := s.reportTarget(kind, strings.TrimPrefix(strings.TrimSpace(r.FormValue("target")), "~"), u)
270+ if !ok {
271+ s.fail(w, r, 404, "Nothing to report there")
272+ return
273+ }
274+ data := map[string]any{"Kind": kind, "Noun": reportKinds[kind], "Target": target, "Reason": r.FormValue("reason")}
275+ if r.Method == "POST" {
276+ if err := s.store.AddReport(u.ID, kind, target, r.FormValue("reason")); err != nil {
277+ w.WriteHeader(400)
278+ s.render(w, r, "report", page{Title: "Report", User: u, Error: err.Error(), Data: data})
279+ return
280+ }
281+ data["Sent"] = true
282+ }
283+ s.render(w, r, "report", page{Title: "Report", User: u, Data: data})
284+}
285+
286+func (s *Server) block(w http.ResponseWriter, r *http.Request) {
287+ u := s.require(w, r)
288+ if u == nil {
289+ return
290+ }
291+ target, err := s.store.UserByName(strings.TrimPrefix(strings.TrimSpace(r.FormValue("name")), "~"))
292+ if err != nil || target.ID == u.ID || s.store.IsOrg(target.ID) {
293+ s.fail(w, r, 400, "You cannot block that account")
294+ return
295+ }
296+ on := r.FormValue("action") != "unblock"
297+ if err := s.store.SetBlock(u.ID, target.ID, on); err != nil {
298+ s.fail(w, r, 500, err.Error())
299+ return
300+ }
301+ back := "/~" + target.Name
302+ if r.FormValue("back") == "settings" {
303+ back = "/settings#blocked"
304+ }
305+ http.Redirect(w, r, back, http.StatusSeeOther)
306+}
307+
308+func (s *Server) moderate(w http.ResponseWriter, r *http.Request, admin *User, fail func(string)) bool {
309+ id, _ := strconv.ParseInt(r.FormValue("report"), 10, 64)
310+ rep, err := s.store.Report(id)
311+ if err != nil {
312+ fail("No such report")
313+ return false
314+ }
315+ switch r.FormValue("do") {
316+ case "dismiss":
317+ s.store.CloseReports(rep.Kind, rep.Target, "dismissed", admin.ID)
318+ case "resolve":
319+ s.store.CloseReports(rep.Kind, rep.Target, "resolved", admin.ID)
320+ case "ban":
321+ name := s.reportAuthor(rep)
322+ if name == "" {
323+ fail("Could not tell who wrote that")
324+ return false
325+ }
326+ if name == admin.Name {
327+ fail("You cannot ban yourself")
328+ return false
329+ }
330+ if err := s.store.SetBanned(name, true); err != nil {
331+ fail("No such account")
332+ return false
333+ }
334+ s.store.CloseReports(rep.Kind, rep.Target, "resolved", admin.ID)
335+ case "pages":
336+ if err := s.store.SetPages(rep.Owner(), false); err != nil {
337+ fail("No such account")
338+ return false
339+ }
340+ s.store.CloseReports(rep.Kind, rep.Target, "resolved", admin.ID)
341+ case "delete":
342+ if rep.Kind != "repo" || r.FormValue("confirm") != rep.Target {
343+ fail("Type the repository path to confirm deletion")
344+ return false
345+ }
346+ owner, name, _ := strings.Cut(rep.Target, "/")
347+ repo, err := s.store.RepoByPath(owner, name)
348+ if err != nil {
349+ fail("That repository is already gone")
350+ return false
351+ }
352+ if err := s.store.DeleteRepo(repo.ID); err != nil {
353+ fail(err.Error())
354+ return false
355+ }
356+ os.RemoveAll(s.repoDir(repo))
357+ s.store.CloseReports(rep.Kind, rep.Target, "resolved", admin.ID)
358+ default:
359+ fail("Unknown action")
360+ return false
361+ }
362+ return true
363+}
364+
365+func (s *Server) reportAuthor(rep Report) string {
366+ if rep.Kind == "user" || rep.Kind == "repo" {
367+ return rep.Owner()
368+ }
369+ path, num, _ := strings.Cut(rep.Target, "#")
370+ owner, name, _ := strings.Cut(path, "/")
371+ repo, err := s.store.RepoByPath(owner, name)
372+ if err != nil {
373+ return ""
374+ }
375+ n, err := strconv.ParseInt(num, 10, 64)
376+ if err != nil {
377+ return ""
378+ }
379+ if rep.Kind == "issue" {
380+ if issue, err := s.store.Issue(repo.ID, n); err == nil {
381+ return issue.Author.Name
382+ }
383+ return ""
384+ }
385+ if pull, err := s.store.Pull(repo, n); err == nil {
386+ return pull.Author.Name
387+ }
388+ return ""
389+}
390+
391+func reportURL(kind, target string) string {
392+ return "/report?kind=" + kind + "&target=" + url.QueryEscape(target)
393+}

forge/pull.go modified

@@ -500,6 +500,14 @@ func (s *Server) createPull(w http.ResponseWriter, r *http.Request, repo Repo) {
500500 retry("This repository is archived")
501501 return
502502 }
503+ if s.shunned(repo, user) {
504+ retry("You cannot open pull requests here")
505+ return
506+ }
507+ if !s.store.ThreadBudget(user.ID) {
508+ retry("Too many new issues and pull requests, try again later")
509+ return
510+ }
503511 src, branch, err := s.resolveSource(repo, user, r.FormValue("source"))
504512 if err != nil {
505513 retry(err.Error())
@@ -571,6 +579,14 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st
571579 return
572580 }
573581 back := fmt.Sprintf("/%s/pulls/%d", repo.Path(), pull.Number)
582+ if repo.Archived {
583+ s.fail(w, r, 403, "This repository is archived")
584+ return
585+ }
586+ if action != "close" && s.shunned(repo, user) {
587+ s.fail(w, r, 403, "You cannot take part here")
588+ return
589+ }
574590 switch action {
575591 case "comment":
576592 if err := s.store.AddComment(pull.ID, *user, r.FormValue("body")); err != nil {
@@ -830,6 +846,15 @@ func (s *Server) fork(w http.ResponseWriter, r *http.Request, repo Repo) {
830846 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
831847 return
832848 }
849+ if s.shunned(repo, user) {
850+ s.fail(w, r, 403, "You cannot fork this repository")
851+ return
852+ }
853+ if s.uploads.blocked("create:" + user.Name) {
854+ s.fail(w, r, 429, "Too many new repositories, try again later")
855+ return
856+ }
857+ s.uploads.fail("create:" + user.Name)
833858 fork, err := s.store.ForkRepo(*user, repo)
834859 if err != nil {
835860 s.fail(w, r, 400, err.Error())

forge/store.go modified

@@ -82,6 +82,7 @@ var migrations = []string{
8282 `alter table users add column admin integer not null default 0`,
8383 `alter table repos add column pinned integer not null default 0`,
8484 `alter table users add column theme text not null default ''`,
85+ `alter table users add column pages integer not null default 1`,
8586 }
8687
8788 const extraSchema = `
@@ -277,7 +278,7 @@ func openStore(path string, readonly bool) (*Store, error) {
277278 if _, err := db.Exec(schema); err != nil {
278279 return nil, err
279280 }
280- for _, ddl := range []string{pullSchema, collabSchema, extraSchema, adminSchema, inboxSchema, followSchema, renameSchema} {
281+ for _, ddl := range []string{pullSchema, collabSchema, extraSchema, adminSchema, inboxSchema, followSchema, renameSchema, moderationSchema} {
281282 if _, err := db.Exec(ddl); err != nil {
282283 return nil, err
283284 }

forge/web.go modified

@@ -295,6 +295,9 @@ func (s *Server) handler() http.Handler {
295295 mux.HandleFunc("POST /settings/avatar/delete", s.deleteAvatar)
296296 mux.HandleFunc("/admin", s.admin)
297297 mux.HandleFunc("/orgs/new", s.newOrg)
298+ mux.HandleFunc("GET /report", s.report)
299+ mux.HandleFunc("POST /report", s.report)
300+ mux.HandleFunc("POST /block", s.block)
298301 mux.HandleFunc("/", s.route)
299302 cop := http.NewCrossOriginProtection()
300303 return secure(s.throttle(cop.Handler(mux)))
@@ -450,6 +453,11 @@ func (s *Server) create(w http.ResponseWriter, r *http.Request) {
450453 }
451454 owner = org
452455 }
456+ if s.uploads.blocked("create:" + u.Name) {
457+ s.fail(w, r, 429, "Too many new repositories, try again later")
458+ return
459+ }
460+ s.uploads.fail("create:" + u.Name)
453461 name := strings.TrimSpace(r.FormValue("name"))
454462 repo, err := s.store.CreateRepo(owner, name, strings.TrimSpace(r.FormValue("description")), visibility(r.FormValue("visibility")), r.FormValue("tags"))
455463 if err != nil {
@@ -613,7 +621,7 @@ func (s *Server) settings(w http.ResponseWriter, r *http.Request) {
613621 }
614622 used := usage(filepath.Join(s.root, "repos", u.Name))
615623 s.render(w, r, "settings", page{Title: "Settings", User: u, Error: r.URL.Query().Get("error"),
616- Data: map[string]any{"Settings": "profile", "Profile": s.store.Profile(u.ID), "Used": used, "Quota": readQuota(s.root), "Account": s.store.Account(u.ID), "Mail": s.mailer != nil, "Sent": r.URL.Query().Get("sent")}})
624+ Data: map[string]any{"Settings": "profile", "Profile": s.store.Profile(u.ID), "Used": used, "Quota": readQuota(s.root), "Account": s.store.Account(u.ID), "Mail": s.mailer != nil, "Sent": r.URL.Query().Get("sent"), "Blocked": s.store.BlockedUsers(u.ID)}})
617625 }
618626
619627 func (s *Server) security(w http.ResponseWriter, r *http.Request) {
@@ -941,6 +949,7 @@ func (s *Server) route(w http.ResponseWriter, r *http.Request) {
941949 data["Orgs"] = orgs
942950 data["Followers"], data["Following"] = s.store.FollowCounts(owner.ID)
943951 data["Followed"] = user != nil && s.store.Follows(user.ID, owner.ID)
952+ data["Blocking"] = user != nil && s.store.Blocked(user.ID, owner.ID)
944953 intro := s.profileReadme(owner)
945954 data["Intro"] = intro
946955 if name := strings.ToLower(strings.TrimLeft(strings.TrimSpace(profile.Workplace), "@~")); validName(name) && name != owner.Name {

web/static/style.css modified

@@ -556,3 +556,6 @@ fieldset.links{border:0;padding:0;margin:0 0 .8rem;display:flex;flex-direction:c
556556 fieldset.links legend{padding:0;margin-bottom:.25rem}
557557 @media(max-width:700px){.inboxpanel{position:fixed;top:3.2rem;left:1rem;right:1rem;width:auto;max-width:none}}
558558 @media(max-width:800px){.btn{white-space:nowrap}.dashhead{flex-wrap:wrap;row-gap:.4rem}.dashhead h3{white-space:nowrap}.columns.pullbody,.columns.browse,.dash:has(>section+section){grid-template-columns:minmax(0,1fr)}nav.tabs a{padding:.25rem .6rem}table.tree .last,table.tree .mode{display:none}table.tree td.name{white-space:normal;overflow-wrap:anywhere}main a,.crumbs{overflow-wrap:anywhere;min-width:0}.userhead{flex-wrap:wrap;gap:.2rem 1rem}.userhead nav.tabs.small{margin-bottom:0;max-width:100%}.titlerow{flex-wrap:wrap;gap:.3rem .8rem}.titlerow .btn{white-space:nowrap}.columns.user,.columns.orgbody{grid-template-columns:minmax(0,1fr)}.heatwrap{overflow-x:auto;direction:rtl;padding-bottom:2.4rem;margin-bottom:-2.4rem}.heatwrap>.heat{direction:ltr;min-width:620px}.heat .c:hover::after,.heat .c:focus::after{bottom:auto;top:calc(100% + 6px)}}
559+.modlinks{margin:.6em 0;text-align:center}
560+button.link{background:none;border:0;padding:0;color:inherit;font:inherit;cursor:pointer;text-decoration:underline}
561+pre.reason{white-space:pre-wrap;word-break:break-word}

web/templates/admin.tmpl modified

@@ -1,6 +1,6 @@
11 {{define "content"}}
22 {{$d := .Data}}
3-<nav class="tabs small admintabs">{{range $d.Sections}}<a href="/admin?s={{.Key}}"{{if eq .Key $d.Section}} class="active"{{end}}>{{.Title}}</a>{{end}}</nav>
3+<nav class="tabs small admintabs">{{range $d.Sections}}<a href="/admin?s={{.Key}}"{{if eq .Key $d.Section}} class="active"{{end}}>{{.Title}}{{if and (eq .Key "reports") $d.OpenReports}} <span class="count">{{$d.OpenReports}}</span>{{end}}</a>{{end}}</nav>
44 {{if $d.Saved}}<p class="notice">Saved.</p>{{end}}
55
66 {{if eq $d.Section "overview"}}
@@ -64,4 +64,20 @@
6464 <h3 class="rule">Usage</h3>
6565 <table class="keys">{{range $d.Owners}}<tr><td><a href="/~{{.Name}}">~{{.Name}}</a></td><td>{{size .Used}}</td><td class="muted">{{if $d.Quota}}{{.Pct}}% of {{$d.QuotaText}}{{end}}</td></tr>{{else}}<tr><td class="muted">No repositories yet.</td></tr>{{end}}</table>
6666 {{end}}
67+{{if eq $d.Section "reports"}}
68+<h3 class="rule">Reports</h3>
69+<nav class="tabs small"><a href="/admin?s=reports"{{if eq $d.State "open"}} class="active"{{end}}>open</a><a href="/admin?s=reports&state=resolved"{{if eq $d.State "resolved"}} class="active"{{end}}>resolved</a><a href="/admin?s=reports&state=dismissed"{{if eq $d.State "dismissed"}} class="active"{{end}}>dismissed</a></nav>
70+{{range $d.Reports}}<div class="event">
71+<h4>{{.Kind}} <a href="{{.Link}}">{{.Target}}</a></h4>
72+<p class="muted small">reported by <a href="/~{{.Reporter.Name}}">~{{.Reporter.Name}}</a> {{ago .Created}}</p>
73+<pre class="reason">{{.Reason}}</pre>
74+{{if eq .State "open"}}<form method="post" action="/admin" class="inline"><input type="hidden" name="s" value="reports"><input type="hidden" name="action" value="moderate"><input type="hidden" name="report" value="{{.ID}}">
75+<button class="btn small" name="do" value="dismiss">Dismiss</button>
76+<button class="btn small" name="do" value="resolve">Mark resolved</button>
77+<button class="btn small danger" name="do" value="ban">Ban author</button>
78+<button class="btn small danger" name="do" value="pages">Disable pages for ~{{.Owner}}</button>
79+</form>
80+{{if eq .Kind "repo"}}<form method="post" action="/admin" class="inline"><input type="hidden" name="s" value="reports"><input type="hidden" name="action" value="moderate"><input type="hidden" name="report" value="{{.ID}}"><input name="confirm" placeholder="{{.Target}}" aria-label="Type {{.Target}} to confirm"><button class="btn small danger" name="do" value="delete">Delete repository</button></form>{{end}}{{end}}
81+</div>{{else}}<p class="info">Nothing here.</p>{{end}}
82+{{end}}
6783 {{end}}

web/templates/issue.tmpl modified

@@ -3,7 +3,7 @@
33 {{$i := .Data.Issue}}
44 <div class="pulltitle">
55 <h3 class="plain"><span class="state {{$i.State}}">{{$i.State}}</span> {{$i.Title}} <span class="muted">#{{$i.Number}}</span>{{range .Data.Labels}} <span class="label" style="background:{{.Colour}}">{{.Name}}</span>{{end}}</h3>
6-<p class="muted"><a href="/~{{$i.Author.Name}}">~{{$i.Author.Name}}</a> opened this issue {{ago $i.Created}}{{if .Data.Comments}} · {{len .Data.Comments}} comments{{end}}</p>
6+<p class="muted"><a href="/~{{$i.Author.Name}}">~{{$i.Author.Name}}</a> opened this issue {{ago $i.Created}}{{if .Data.Comments}} · {{len .Data.Comments}} comments{{end}}{{if .User}} · <a class="muted" href="/report?kind=issue&amp;target={{$p.Repo.Path}}%23{{$i.Number}}">report</a>{{end}}</p>
77 </div>
88 <div class="columns pullbody">
99 <div class="main">

web/templates/layout.tmpl modified

@@ -37,7 +37,7 @@
3737 </header>
3838 {{with .Data}}{{with index . "Page"}}
3939 <div class="repohead">
40-<div class="inner"><div class="titlerow"><h2><a href="/~{{.Repo.Owner.Name}}">~{{.Repo.Owner.Name}}</a>/{{.Repo.Name}}{{if .Repo.Private}} <span class="tag">private</span>{{end}}</h2>{{if .User}}<form method="post" action="/{{.Repo.Path}}/star"><button class="btn{{if .Starred}} starred{{end}}"><svg class="staricon" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.6l1.9 4 4.4.5-3.3 3 .9 4.3L8 11.2 4.1 13.4l.9-4.3-3.3-3 4.4-.5z"/></svg>{{if .Starred}}Starred{{else}}Star{{end}} {{.Stars}}</button></form>{{else if .Stars}}<span class="btn static"><svg class="staricon" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.6l1.9 4 4.4.5-3.3 3 .9 4.3L8 11.2 4.1 13.4l.9-4.3-3.3-3 4.4-.5z"/></svg>{{.Stars}}</span>{{end}}{{if and .User (not .Owner) (not .Empty)}}<form method="post" action="/{{.Repo.Path}}/fork"><button class="btn"><svg class="forkicon" viewBox="0 0 16 16" aria-hidden="true"><circle cx="4" cy="3" r="1.6"/><circle cx="12" cy="3" r="1.6"/><circle cx="8" cy="13" r="1.6"/><path d="M4 4.6v1.4a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V4.6M8 8v3.4"/></svg>Fork {{.Repo.Forks}}</button></form>{{else if .Repo.Forks}}<span class="btn static"><svg class="forkicon" viewBox="0 0 16 16" aria-hidden="true"><circle cx="4" cy="3" r="1.6"/><circle cx="12" cy="3" r="1.6"/><circle cx="8" cy="13" r="1.6"/><path d="M4 4.6v1.4a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V4.6M8 8v3.4"/></svg>{{.Repo.Forks}}</span>{{end}}</div>{{if .Repo.Archived}}<p class="archived">This repository is archived. It is read only.</p>{{end}}{{with .Parent}}<p class="desc">forked from <a href="/{{.Path}}">{{.Path}}</a></p>{{end}}{{if and .Repo.Description (eq .Tab "summary")}}<p class="desc">{{.Repo.Description}}</p>{{end}}</div>
40+<div class="inner"><div class="titlerow"><h2><a href="/~{{.Repo.Owner.Name}}">~{{.Repo.Owner.Name}}</a>/{{.Repo.Name}}{{if .Repo.Private}} <span class="tag">private</span>{{end}}</h2>{{if .User}}<form method="post" action="/{{.Repo.Path}}/star"><button class="btn{{if .Starred}} starred{{end}}"><svg class="staricon" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.6l1.9 4 4.4.5-3.3 3 .9 4.3L8 11.2 4.1 13.4l.9-4.3-3.3-3 4.4-.5z"/></svg>{{if .Starred}}Starred{{else}}Star{{end}} {{.Stars}}</button></form>{{else if .Stars}}<span class="btn static"><svg class="staricon" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.6l1.9 4 4.4.5-3.3 3 .9 4.3L8 11.2 4.1 13.4l.9-4.3-3.3-3 4.4-.5z"/></svg>{{.Stars}}</span>{{end}}{{if and .User (not .Owner) (not .Empty)}}<form method="post" action="/{{.Repo.Path}}/fork"><button class="btn"><svg class="forkicon" viewBox="0 0 16 16" aria-hidden="true"><circle cx="4" cy="3" r="1.6"/><circle cx="12" cy="3" r="1.6"/><circle cx="8" cy="13" r="1.6"/><path d="M4 4.6v1.4a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V4.6M8 8v3.4"/></svg>Fork {{.Repo.Forks}}</button></form>{{else if .Repo.Forks}}<span class="btn static"><svg class="forkicon" viewBox="0 0 16 16" aria-hidden="true"><circle cx="4" cy="3" r="1.6"/><circle cx="12" cy="3" r="1.6"/><circle cx="8" cy="13" r="1.6"/><path d="M4 4.6v1.4a2 2 0 0 0 2 2h4a2 2 0 0 0 2-2V4.6M8 8v3.4"/></svg>{{.Repo.Forks}}</span>{{end}}</div>{{if .Repo.Archived}}<p class="archived">This repository is archived. It is read only.</p>{{end}}{{if and .User (not .IsOwner)}}<a class="muted small reportlink" href="/report?kind=repo&amp;target={{.Repo.Path}}">report</a>{{end}}{{with .Parent}}<p class="desc">forked from <a href="/{{.Path}}">{{.Path}}</a></p>{{end}}{{if and .Repo.Description (eq .Tab "summary")}}<p class="desc">{{.Repo.Description}}</p>{{end}}</div>
4141 <div class="tabbar"><nav class="tabs inner">
4242 <a href="/{{.Repo.Path}}"{{if eq .Tab "summary"}} class="active"{{end}}>summary</a>
4343 {{if not .Empty}}<a href="/{{.Repo.Path}}/tree/{{.Ref}}/"{{if eq .Tab "tree"}} class="active"{{end}}>tree</a><a href="/{{.Repo.Path}}/log/{{.Ref}}"{{if eq .Tab "log"}} class="active"{{end}}>log</a><a href="/{{.Repo.Path}}/refs"{{if eq .Tab "refs"}} class="active"{{end}}>branches</a><a href="/{{.Repo.Path}}/pulls"{{if eq .Tab "pulls"}} class="active"{{end}}>pull requests{{if .Open}} <span class="count">{{.Open}}</span>{{end}}</a>{{end}}<a href="/{{.Repo.Path}}/issues"{{if eq .Tab "issues"}} class="active"{{end}}>issues{{if .OpenIssues}} <span class="count">{{.OpenIssues}}</span>{{end}}</a>

web/templates/pull.tmpl modified

@@ -3,7 +3,7 @@
33 {{$pr := .Data.Pull}}
44 <div class="pulltitle">
55 <h3 class="plain"><span class="state {{$pr.State}}{{if $pr.Draft}} draft{{end}}">{{if and $pr.Draft (eq $pr.State "open")}}draft{{else}}{{$pr.State}}{{end}}</span> {{$pr.Title}} <span class="muted">#{{$pr.Number}}</span>{{range .Data.Labels}} <span class="label" style="background:{{.Colour}}">{{.Name}}</span>{{end}}</h3>
6-<p class="muted"><a href="/~{{$pr.Author.Name}}">~{{$pr.Author.Name}}</a> wants to merge {{len .Data.Commits}} commit{{if ne (len .Data.Commits) 1}}s{{end}} from <code>{{if $pr.SameRepo}}{{$pr.SourceBranch}}{{else}}{{$pr.SourceRepo.Path}}:{{$pr.SourceBranch}}{{end}}</code> into <code>{{$pr.TargetBranch}}</code> · opened {{ago $pr.Created}}{{if eq $pr.State "merged"}} · merged as <a href="/{{$p.Repo.Path}}/commit/{{$pr.Merged}}"><code>{{short $pr.Merged}}</code></a>{{end}}</p>
6+<p class="muted"><a href="/~{{$pr.Author.Name}}">~{{$pr.Author.Name}}</a> wants to merge {{len .Data.Commits}} commit{{if ne (len .Data.Commits) 1}}s{{end}} from <code>{{if $pr.SameRepo}}{{$pr.SourceBranch}}{{else}}{{$pr.SourceRepo.Path}}:{{$pr.SourceBranch}}{{end}}</code> into <code>{{$pr.TargetBranch}}</code> · opened {{ago $pr.Created}}{{if .User}} · <a class="muted" href="/report?kind=pull&amp;target={{$p.Repo.Path}}%23{{$pr.Number}}">report</a>{{end}}{{if eq $pr.State "merged"}} · merged as <a href="/{{$p.Repo.Path}}/commit/{{$pr.Merged}}"><code>{{short $pr.Merged}}</code></a>{{end}}</p>
77 </div>
88 <div class="columns pullbody">
99 <div class="main">

web/templates/report.tmpl added

@@ -0,0 +1,13 @@
1+{{define "content"}}
2+{{$d := .Data}}
3+<div class="panel narrow">
4+<h3>Report {{$d.Noun}}</h3>
5+{{if $d.Sent}}<p>Thanks, the report about <code>{{$d.Target}}</code> went to the admins.</p><p><a href="/">Back to the front page</a></p>{{else}}
6+<form method="post" action="/report" class="stack">
7+<input type="hidden" name="kind" value="{{$d.Kind}}"><input type="hidden" name="target" value="{{$d.Target}}">
8+<p class="muted">Reporting <code>{{$d.Target}}</code>. Only admins see this.</p>
9+<label>What is wrong <textarea name="reason" rows="6" maxlength="2000" required autofocus>{{$d.Reason}}</textarea></label>
10+<div class="actions"><button class="btn primary">Send report ▸</button></div>
11+</form>{{end}}
12+</div>
13+{{end}}

web/templates/settings.tmpl modified

@@ -46,4 +46,7 @@
4646 <label>Bio <textarea name="bio" rows="6" maxlength="4000" placeholder="Markdown supported, paste or drop images to attach them" data-md>{{$p.Bio}}</textarea></label>
4747 <div class="actions"><button class="btn primary">Save changes ▸</button></div>
4848 </form>
49+<h3 class="rule" id="blocked">Blocked accounts</h3>
50+<p class="muted small">Blocked accounts cannot open issues or pull requests, comment, star or fork your repositories, or follow you.</p>
51+{{if .Data.Blocked}}<table class="keys">{{range .Data.Blocked}}<tr><td><a href="/~{{.Name}}">~{{.Name}}</a></td><td><form method="post" action="/block" class="inline"><input type="hidden" name="name" value="{{.Name}}"><input type="hidden" name="back" value="settings"><button class="btn small" name="action" value="unblock">Unblock</button></form></td></tr>{{end}}</table>{{else}}<p class="muted">Nobody.</p>{{end}}
4952 {{end}}

web/templates/user.tmpl modified

@@ -10,7 +10,8 @@
1010 <img class="avatar" src="/~{{.Data.Owner.Name}}/avatar?v={{$p.Avatar}}" alt="">
1111 <div class="ident">{{if $p.Display}}<p class="name">{{$p.Display}}</p>{{end}}<p class="handle">~{{.Data.Owner.Name}}</p></div>
1212 {{with .Data.Bio}}<div class="bio readme">{{.}}</div>{{end}}
13-{{if and .User (not $own)}}<form method="post" action="/~{{.Data.Owner.Name}}/follow" class="followform"><button class="btn block{{if not .Data.Followed}} primary{{end}}">{{if .Data.Followed}}Unfollow{{else}}Follow{{end}}</button></form>{{end}}
13+{{if and .User (not $own)}}<form method="post" action="/~{{.Data.Owner.Name}}/follow" class="followform"><button class="btn block{{if not .Data.Followed}} primary{{end}}">{{if .Data.Followed}}Unfollow{{else}}Follow{{end}}</button></form>
14+<div class="small muted modlinks"><form method="post" action="/block" class="inline"><input type="hidden" name="name" value="{{.Data.Owner.Name}}">{{if .Data.Blocking}}<button class="link" name="action" value="unblock">unblock</button>{{else}}<button class="link" name="action" value="block">block</button>{{end}}</form> · <a href="/report?kind=user&amp;target={{.Data.Owner.Name}}">report</a></div>{{end}}
1415 <ul class="facts">
1516 <li class="followcount"><svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="6" cy="5.5" r="2.4"/><path d="M1.8 13.5c.5-2.4 2.2-3.7 4.2-3.7s3.7 1.3 4.2 3.7"/><path d="M10.4 3.3a2.4 2.4 0 0 1 0 4.4M12 9.9c1.2.5 2 1.7 2.2 3.6"/></svg><a href="/~{{.Data.Owner.Name}}/followers">{{.Data.Followers}} follower{{if ne .Data.Followers 1}}s{{end}}</a><span class="sep">·</span><a href="/~{{.Data.Owner.Name}}/following">{{.Data.Following}} following</a></li>
1617 {{if $p.Location}}<li><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M8 15s5-4.6 5-8.5A5 5 0 0 0 3 6.5C3 10.4 8 15 8 15z"/><circle cx="8" cy="6.5" r="1.8"/></svg>{{$p.Location}}</li>{{end}}